Next.js (server-side rendering)

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:

git clone https://github.com/opengovsg/sgid-client.git
cd sgid-client/examples/nextjs-ssr
cat .env.example > .env # Copy the `.env.example` file
npm install

Step 2: Update your environment variables

Update your .env file with your client credentials.

examples/nextjs-ssr/.env
SGID_CLIENT_ID=<your client id>
SGID_CLIENT_SECRET=<your client secret>
SGID_PRIVATE_KEY=<your private key>

Step 3: Run the example

# In the /sgid-client/examples/nextjs-ssr directory
npm run dev

Visit http://localhost:5001 to check that your app is running.

If you click on 'Login with Singpass' and authenticate with your Singpass mobile app, you should see your user info on the success screen.

Breaking the example down

In this section, we'll break down the different steps that our example app goes through.

Step 1: Initialize the SDK

In this step, we will create an instance of our SgidClient class which will help us to interface with the sgID server.

In the .env file created from the previous step, fill out your sgID credentials.

examples/nextjs-ssr/.env
SGID_CLIENT_ID=<your client id>
SGID_CLIENT_SECRET=<your client secret>
SGID_PRIVATE_KEY=<your private key>

The main idea here is to load your sgID credentials in a secure way using environment variables instead of hard-coding them into your app.

Next, Initialize the SDK by calling the constructor and providing your client credentials.

src/lib/sgidClient.ts
// Server-side code
import { SgidClient } from '@opengovsg/sgid-client'

const sgidClient = new SgidClient({
  clientId: String(process.env.SGID_CLIENT_ID),
  clientSecret: String(process.env.SGID_CLIENT_SECRET),
  privateKey: String(process.env.SGID_PRIVATE_KEY),
  redirectUri: 'http://localhost:5001/success',
})

export { sgidClient }

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
}

type Session = {
  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 = new Map<string, Session>()
} else {
  // If the store does not exist, initialize it
  if (!global.store) {
    global.store = new Map<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";

export async function middleware(req: NextRequest) {
  switch (req.nextUrl.pathname) {
    case "/":
      // Generate new session ID
      const sessionId = req.cookies.get("sessionId")?.value || uuidv4();

      // Set session ID in cookie
      const res = 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.

src/components/LoginWithIceCream.tsx
// Client-side code
"use client";

import { useState } from "react";
import Link from "next/link";

const flavours = ["Vanilla", "Chocolate", "Strawberry"] as const;
type IceCream = (typeof flavours)[number];

const LoginWithIceCream = () => {
  const [state, setState] = useState<IceCream>("Vanilla");

  return (
    <div>
        <h2>Favourite ice cream flavour</h2>
        <div>
          {flavours.map((flavour) => (
            <div
              key={flavour}
              onClick={() => setState(flavour)}
            >
              <input
                type="radio"
                checked={state === flavour}
                value={flavour}
                onChange={(e) => setState(e.target.value as IceCream)}
                title="flavour"
              />
              {flavour}
            </div>
          ))}
        </div>

        <Link
          prefetch={false}
          href={`/login?state=${state}`}
        >
          <button>
            Login with Singpass app
          </button>
        </Link>
      </div>
  );
};

export default LoginWithIceCream;

Next, add the log in button to the home page as such.

src/app/page.tsx
// Server-side code
import LoginWithIceCream from "@/components/LoginWithIceCream";

export default function Home() {
  return (
    <main>
      <LoginWithIceCream />
    </main>
  );
}

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 code
import { 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";

const handleLogin = async (state: string) => {
  const sessionId = cookies().get("sessionId")?.value || "";

  if (!sessionId) {
    throw new Error("Session ID not found in browser's cookies");
  }

  // Generate PKCE pair
  const { codeChallenge, codeVerifier } = generatePkcePair();

  // Generate authorization url
  const { url, nonce } = sgidClient.authorizationUrl({
    state,
    codeChallenge,
  });

  // Store code verifier, state, and nonce in memory
  store.set(sessionId, { state, codeVerifier, nonce });

  redirect(url);
};

export default async function Login(
{ searchParams }: { searchParams: { [key: string]: string | undefined }}
) {
  await handleLogin(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.

  1. The authorization code is retrieved from the URL search params

  2. The code is exchanged for an access token

  3. The user info is requested with the access token

  4. The user info is stored in-memory for subsequent requests

  5. The user info is used to generate the HTML page on the server

src/app/success/page.tsx
// Server-side code
import { store } from '@/lib/store'
import { sgidClient } from '@/lib/sgidClient'
import { cookies } from 'next/headers'
import Link from 'next/link'

const getAndStoreUserInfo = async (code: string, sessionId: string) => {
  const session = store.get(sessionId)

  if (!session) {
    throw new Error('Session not found')
  }

  const { nonce, codeVerifier } = session

  if (!codeVerifier) {
    throw new Error('Code verifier not found')
  }

  // Exchange auth code for access token
  const { accessToken, sub } = await sgidClient.callback({
    code,
    nonce,
    codeVerifier,
  })

  // Request user info with access token
  const { data } = await sgidClient.userinfo({
    accessToken,
    sub,
  })

  // Store userInfo and sgID in memory
  const updatedSession = {
    ...session,
    userInfo: data,
    sub, // `sub` is a unique sgID identifier for your end-user
  }
  store.set(sessionId, updatedSession)

  return updatedSession
}

export default async function Redirect({
  searchParams,
}: {
  searchParams: { [key: string]: string | undefined }
}) {
  // Auth code is retrieved from the URL search params
  const code = searchParams?.code
  const sessionId = cookies().get('sessionId')?.value
  if (!code) {
    throw new Error(
      'Authorization code is not present in the url search params',
    )
  } else if (!sessionId) {
    throw new Error("Session ID not found in browser's cookies")
  }

  const { state, userInfo, sub } = await getAndStoreUserInfo(code, sessionId)

  // HTML page is generated in the server
  return (
    <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 code
import { store } from "@/lib/store";
import { cookies } from "next/headers";

const getUserInfo = async (sessionId: string) => {
  // Retrieve session from memory
  const session = store.get(sessionId);
  if (!session) {
    throw new Error("No session found");
  }
  return session;
};

export default async function LoggedIn() {
  const sessionId = cookies().get("sessionId")?.value;

  if (!sessionId) {
    throw new Error("Session ID not found in browser's cookies");
  }

  const { sub, userInfo } = await getUserInfo(sessionId);
  
  if (!sub || !userInfo) {
    throw new Error("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.

Congratulations! 🎉

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.

If you want to find out more about how sgID works, click here to learn about the sgID protocol.

If you have more questions about sgID, check out our FAQ for answers to common questions.

Last updated