This page provides a step-by-step guide on how to integrate the TypeScript SDK in a Next.js (>= 13.4) project with server-side rendering (SSR) using the app router, server components, and middleware.
To illustrate our example, we will create a demo app which will allow you to retrieve your user's name and favorite ice cream flavor after they log in with sgID.
If you have not already obtained your client credentials via registration, please register your client before proceeding.
For this example, you should add:
1. [openid, myinfo.name] as the scopes and
2. http://localhost:5001/success as a redirect URL
Running the example locally
Step 1: Clone the repo
To run the example locally, clone from our source code by running:
gitclonehttps://github.com/opengovsg/sgid-client.gitcdsgid-client/examples/nextjs-ssrcat.env.example>.env# Copy the `.env.example` filenpminstall
Step 2: Update your environment variables
Update your .env file with your client credentials.
Step 2: Initialize an in-memory store for sessions
To maintain session data across requests, we will need to initialize an in-memory store for our server.
In a production environment, your session data should be stored in a database but for our example, we will be using an in-memory store to reduce complexity.
src/lib/store.ts
/** * We place the store in the global object to prevent it from being cleared whenever compilation happens */declare global {var store:Map<string,Session> |undefined}typeSession= { state?:string; nonce?:string; codeVerifier?:string; accessToken?:string; userInfo?:Record<string,string>; sub?:string;};let store:Map<string,Session>if (process.env.NODE_ENV==='production') { store =newMap<string,Session>()} else {// If the store does not exist, initialize itif (!global.store) {global.store =newMap<string,Session>() } store =global.store}export { store }
Step 3: Create the middleware for setting a session ID in cookies
The purpose of this middleware is to generate a session ID and set it in the browser's cookies when the user visits the site. This session ID is important for the server to identify the browser and track the session data through the login flow.
src/middleware.tsx
import { NextRequest, NextResponse } from"next/server";import { v4 as uuidv4 } from"uuid";exportasyncfunctionmiddleware(req:NextRequest) {switch (req.nextUrl.pathname) {case"/":// Generate new session IDconstsessionId=req.cookies.get("sessionId")?.value ||uuidv4();// Set session ID in cookieconstres=NextResponse.next();res.cookies.set({ name:"sessionId", value: sessionId, httpOnly:true, });return res; }}
Step 4: Create a button to redirect to /login
Now, we need to create a button to redirect the browser to the /login page. Additionally, we will be creating an ice cream flavour selector in order to demonstrate the OAuth 2.0 protocol's ability to carry over state.
In the following examples, we will not include any styling in order to keep the code snippet short. If you would like to view and interact with an example with styling, feel free to refer to the source code.
As we will be using useState, this component will be a client component.
Step 5: Create the /login page which redirects to the authorization URL
When the user clicks on the 'Login with Singpass' button, it should direct them to the /login page which will generate the authorization URL to redirect the browser to.
The server function handleLogin will do the following.
Retrieve the session ID from the browser cookies (that was set by the middleware)
Generate a PKCE pair (consisting of code challenge and code verifier)
Generate an authorization URL
Store the code verifier in the in-memory store with the session ID as the key
Redirect the browser to the authorization URL
src/app/login/page.tsx
// Server-side codeimport { redirect } from"next/navigation";import { sgidClient } from"@/lib/sgidClient";import { NextSSRPage } from"@/types";import { cookies } from"next/headers";import { store } from"@/lib/store";import { generatePkcePair } from"@opengovsg/sgid-client";consthandleLogin=async (state:string) => {constsessionId=cookies().get("sessionId")?.value ||"";if (!sessionId) {thrownewError("Session ID not found in browser's cookies"); }// Generate PKCE pairconst { codeChallenge,codeVerifier } =generatePkcePair();// Generate authorization urlconst { url,nonce } =sgidClient.authorizationUrl({ state, codeChallenge, });// Store code verifier, state, and nonce in memorystore.set(sessionId, { state, codeVerifier, nonce });redirect(url);};exportdefaultasyncfunctionLogin({ searchParams }: { searchParams: { [key:string]:string|undefined }}) {awaithandleLogin(searchParams?.state ||"");return <></>;}
Step 6: Create the login /success page
Once the end-user has successfully authorized your application with their Singpass mobile app, the sgID server will redirect the browser to the provided redirect URL.
When the browser visits this URL, the following will happen.
The authorization code is retrieved from the URL search params
The code is exchanged for an access token
The user info is requested with the access token
The user info is stored in-memory for subsequent requests
The user info is used to generate the HTML page on the server
src/app/success/page.tsx
// Server-side codeimport { store } from'@/lib/store'import { sgidClient } from'@/lib/sgidClient'import { cookies } from'next/headers'import Link from'next/link'constgetAndStoreUserInfo=async (code:string, sessionId:string) => {constsession=store.get(sessionId)if (!session) {thrownewError('Session not found') }const { nonce,codeVerifier } = sessionif (!codeVerifier) {thrownewError('Code verifier not found') }// Exchange auth code for access tokenconst { accessToken,sub } =awaitsgidClient.callback({ code, nonce, codeVerifier, })// Request user info with access tokenconst { data } =awaitsgidClient.userinfo({ accessToken, sub, })// Store userInfo and sgID in memoryconstupdatedSession= {...session, userInfo: data, sub,// `sub` is a unique sgID identifier for your end-user }store.set(sessionId, updatedSession)return updatedSession}exportdefaultasyncfunctionRedirect({ searchParams,}: { searchParams: { [key:string]:string|undefined }}) {// Auth code is retrieved from the URL search paramsconstcode=searchParams?.codeconstsessionId=cookies().get('sessionId')?.valueif (!code) {thrownewError('Authorization code is not present in the url search params', ) } elseif (!sessionId) {thrownewError("Session ID not found in browser's cookies") }const { state,userInfo,sub } =awaitgetAndStoreUserInfo(code, sessionId)// HTML page is generated in the serverreturn ( <div> <div> Logged in successfully!</div> {sub ? ( <div>{`sgID: ${sub}`}</div> ) : null} {Object.entries(userInfo ?? {}).map(([field, value]) => ( <div>{`${field}: ${value}`}</div> ))} {state ? ( <div> {`Favourite ice cream flavour: ${state}`} </div> ) : null} <Link href="/user-info"> View user info </Link> </div> )}
Now if you run your Next.js app, click on Login with Singpass and complete the authorization flow with your Singpass mobile app, you should be brought to this success page where you can view your personal details as well as your ice cream flavour selected on the login page.
If this is sufficient for your application and you do not need user data for any other further actions, congratulations! You can stop reading here. However, if you would like to use user data on other routes and pages, continue on to the next step.
Step 7: Create a /userinfo page to fetch user info from session store
The purpose of this page is to demonstrate how to retrieve the user data stored in-memory for subsequent requests. We will simply be creating a dummy page that displays the user info just like in the previous step.
src/app/user-info/page.tsx
// Server-side codeimport { store } from"@/lib/store";import { cookies } from"next/headers";constgetUserInfo=async (sessionId:string) => {// Retrieve session from memoryconstsession=store.get(sessionId);if (!session) {thrownewError("No session found"); }return session;};exportdefaultasyncfunctionLoggedIn() {constsessionId=cookies().get("sessionId")?.value;if (!sessionId) {thrownewError("Session ID not found in browser's cookies"); }const { sub,userInfo } =awaitgetUserInfo(sessionId);if (!sub ||!userInfo) {thrownewError("User has not authenticated"); }return ( <div> <div>User Info</div> {sub ? ( <div>{`sgID: ${sub}`}</div> ) : null} {Object.entries(userInfo ?? {}).map(([field, value]) => ( <div>{`${field}: ${value}`}</div> ))} </div> );}
If you complete the log in flow once again but click on the 'View user info' button, you will be brought to this /login page where you can view user data. To ensure that the user info has been properly stored in the in-memory store, try refreshing the page - you should see that the user info can still be fetched by the page.
You have reached the end of the Next.js (SSR) step-by-step guide.
The source code for this example can be found here which includes an /logout page (and middleware) for logging out, styling with Tailwind CSS, and a README on how to run the example locally.