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.
-
Clone the Phase Two example repo and start its local Keycloak. Its
p2examplesrealm already has the example'snextjsclient and a non-admin user,demo/demo.git clone https://github.com/p2-inc/examples.gitcd examplesdocker compose -f keycloak/docker-compose.yml up -d --wait -
Open the Next.js folder and copy
.env.exampleto.env:cd frameworks/nextjscp .env.example .envThe app reads these variables.
.env.examplealready holds the values for the local Keycloak:Variable Description Local Keycloak NEXTAUTH_URLPublic URL of the app http://localhost:3000NEXTAUTH_SECRETRandom secret that encrypts the session cookie Generate your own KEYCLOAK_IDClient ID nextjsKEYCLOAK_SECRETClient secret nextjs-local-dev-secretKEYCLOAK_ISSUERIssuer URL of the realm http://localhost:8080/auth/realms/p2examplesSet
NEXTAUTH_SECRETin.envto a random value, such as the output ofopenssl 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 setKEYCLOAK_ISSUERto your realm's issuer URL, such ashttps://<your-keycloak-host>/auth/realms/<your-realm>,KEYCLOAK_IDto your client's ID, andKEYCLOAK_SECRETto the secret from its Credentials tab..envis ignored by git, so your secrets stay out of version control. -
Install the dependencies and start the app:
pnpm installpnpm dev -
Open
src/auth.ts. This server-only file reads the Keycloak settings from the environment. ItsrefreshAccessTokenfunction 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,authOptionsconfigures 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
jwtcallback 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 callsrefreshAccessToken. - The
sessioncallback decides what the app gets from the session: the decoded claims of the access and ID tokens, and anerrorif the refresh failed. The tokens themselves never leave the server. - The
signOutevent posts the refresh token to Keycloak's logout endpoint, so logging out of the app also ends the Keycloak session.
decodeJwtPayloadis insrc/lib/jwt.ts, andsrc/types/next-auth.d.tsadds these fields to theJWTandSessiontypes. -
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.tsmounts them withauthOptions:import NextAuth from "next-auth";import { authOptions } from "@/auth";const handler = NextAuth(authOptions);export { handler as GET, handler as POST }; -
src/app/page.tsxis a Server Component. It reads the session on the server withgetServerSession(authOptions)and passes it to theUsercomponent fromsrc/components/user.component.tsx.Usershows "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
signInandsignOutfromnext-auth/react. They are insrc/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 (<buttonclassName={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. -
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.
infoSign in with a non-admin user, such as
demo/demoon the local Keycloak. -
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.
-
Click Log out. The app also ends your Keycloak session, so the next time you click Log in, Keycloak asks for your credentials again.