Skip to main content

Next.js

For a working example, Phase Two has a Next.js project with sample code or view a live deployed version.

This example uses Next.js 16 with the App Router. It logs users in with NextAuth.js 4.24 and its Keycloak provider. The login runs on the server: the tokens stay in an encrypted, HTTP-only session cookie, and the browser only sees their decoded claims. Learn more at our Next.js blog post.

You need Node.js 24, pnpm, and Docker with the Compose plugin for the local Keycloak.

  1. Clone the Phase Two example repo and start its local Keycloak. Its p2examples realm already has the example's nextjs client and a non-admin user, demo / demo.

    git clone https://github.com/p2-inc/examples.git
    cd examples
    docker compose -f keycloak/docker-compose.yml up -d --wait
  2. Open the Next.js folder and copy .env.example to .env:

    cd frameworks/nextjs
    cp .env.example .env

    The app reads these variables. .env.example already holds the values for the local Keycloak:

    VariableDescriptionLocal Keycloak
    NEXTAUTH_URLPublic URL of the apphttp://localhost:3000
    NEXTAUTH_SECRETRandom secret that encrypts the session cookieGenerate your own
    KEYCLOAK_IDClient IDnextjs
    KEYCLOAK_SECRETClient secretnextjs-local-dev-secret
    KEYCLOAK_ISSUERIssuer URL of the realmhttp://localhost:8080/auth/realms/p2examples

    Set NEXTAUTH_SECRET in .env to a random value, such as the output of openssl rand -base64 32. It is not the Keycloak client secret.

    To use another Keycloak, create a confidential OpenID Connect client in your realm: turn Client authentication on, keep Standard flow checked, and set Valid redirect URIs to http://localhost:3000/* and Valid post logout redirect URIs to +. Then set KEYCLOAK_ISSUER to your realm's issuer URL, such as https://<your-keycloak-host>/auth/realms/<your-realm>, KEYCLOAK_ID to your client's ID, and KEYCLOAK_SECRET to the secret from its Credentials tab. .env is ignored by git, so your secrets stay out of version control.

  3. Install the dependencies and start the app:

    pnpm install
    pnpm dev
  4. Open src/auth.ts. This server-only file reads the Keycloak settings from the environment. Its refreshAccessToken function trades the refresh token for new tokens at Keycloak's token endpoint:

    import type { NextAuthOptions } from "next-auth";
    import type { JWT } from "next-auth/jwt";
    import KeycloakProvider from "next-auth/providers/keycloak";
    import { decodeJwtPayload } from "@/lib/jwt";

    const issuer = process.env.KEYCLOAK_ISSUER ?? "";
    const clientId = process.env.KEYCLOAK_ID ?? "";
    const clientSecret = process.env.KEYCLOAK_SECRET ?? "";

    async function refreshAccessToken(token: JWT): Promise<JWT> {
    if (!token.refreshToken) {
    return { ...token, error: "RefreshAccessTokenError" };
    }

    const response = await fetch(`${issuer}/protocol/openid-connect/token`, {
    method: "POST",
    body: new URLSearchParams({
    grant_type: "refresh_token",
    client_id: clientId,
    client_secret: clientSecret,
    refresh_token: token.refreshToken,
    }),
    });

    if (!response.ok) {
    return { ...token, error: "RefreshAccessTokenError" };
    }

    const tokens = await response.json();
    return {
    ...token,
    accessToken: tokens.access_token,
    idToken: tokens.id_token ?? token.idToken,
    refreshToken: tokens.refresh_token ?? token.refreshToken,
    expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
    error: undefined,
    };
    }

    Further down in src/auth.ts, authOptions configures NextAuth.js with the Keycloak provider. The route handler and the page both use it:

    export const authOptions: NextAuthOptions = {
    providers: [KeycloakProvider({ clientId, clientSecret, issuer })],
    session: { strategy: "jwt" },
    callbacks: {
    async jwt({ token, account }) {
    if (account) {
    return {
    ...token,
    accessToken: account.access_token,
    idToken: account.id_token,
    refreshToken: account.refresh_token,
    expiresAt: account.expires_at,
    };
    }

    if (token.expiresAt && Date.now() < (token.expiresAt - 30) * 1000) {
    return token;
    }

    return refreshAccessToken(token);
    },
    async session({ session, token }) {
    return {
    ...session,
    error: token.error,
    accessTokenClaims: decodeJwtPayload(token.accessToken),
    idTokenClaims: decodeJwtPayload(token.idToken),
    };
    },
    },
    events: {
    async signOut({ token }) {
    if (!token.refreshToken) {
    return;
    }

    await fetch(`${issuer}/protocol/openid-connect/logout`, {
    method: "POST",
    body: new URLSearchParams({
    client_id: clientId,
    client_secret: clientSecret,
    refresh_token: token.refreshToken,
    }),
    });
    },
    },
    };
    • session: { strategy: "jwt" } keeps the session in the encrypted cookie, so the app needs no database.
    • The jwt callback stores Keycloak's access, ID and refresh tokens in that cookie when the user logs in. When the access token has less than 30 seconds left, it calls refreshAccessToken.
    • The session callback decides what the app gets from the session: the decoded claims of the access and ID tokens, and an error if the refresh failed. The tokens themselves never leave the server.
    • The signOut event posts the refresh token to Keycloak's logout endpoint, so logging out of the app also ends the Keycloak session.

    decodeJwtPayload is in src/lib/jwt.ts, and src/types/next-auth.d.ts adds these fields to the JWT and Session types.

  5. NextAuth.js serves its own routes under /api/auth, such as the callback that Keycloak redirects to after the login. src/app/api/auth/[...nextauth]/route.ts mounts them with authOptions:

    import NextAuth from "next-auth";
    import { authOptions } from "@/auth";

    const handler = NextAuth(authOptions);

    export { handler as GET, handler as POST };
  6. src/app/page.tsx is a Server Component. It reads the session on the server with getServerSession(authOptions) and passes it to the User component from src/components/user.component.tsx. User shows "Not authenticated." and a Log in button, or your name and email, the decoded tokens and a Log out button. If the token refresh failed, it asks you to log in again.

    The buttons call signIn and signOut from next-auth/react. They are in src/components/buttons.components.tsx, the app's only Client Component:

    "use client";

    import { signIn, signOut } from "next-auth/react";

    const buttonClasses =
    "cursor-pointer rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600";

    export function LoginButton() {
    return (
    <button className={buttonClasses} onClick={() => signIn("keycloak")}>
    Log in
    </button>
    );
    }

    export function LogoutButton() {
    return (
    <button
    className={buttonClasses}
    onClick={() => signOut({ callbackUrl: "/" })}
    >
    Log out
    </button>
    );
    }

    Protect other Server Components, route handlers and server actions the same way: call getServerSession(authOptions) and check that it returns a session.

  7. Open localhost:3000. You will see the Phase Two example landing page. Your current status should be "Not authenticated." Click Log in. This will redirect you to the Keycloak login page.

    info

    Sign in with a non-admin user, such as demo / demo on the local Keycloak.

  8. Enter the user's credentials and sign in. You will then be redirected to the application. The Phase Two example landing page now shows your "Authenticated" state, your user's name and email, and the decoded access token and ID token.

  9. Click Log out. The app also ends your Keycloak session, so the next time you click Log in, Keycloak asks for your credentials again.