React Router v7 (formerly Remix)
View a live deployed version.
We will use the Phase Two React Router example code here, but the logic could easily be applied to any existing application. Remix v2 is now React Router v7, and the example is the Remix example migrated to React Router's framework mode.
-
Clone the Phase Two example repo.
-
Open the React Router folder within
/frameworks/remix. The folder keeps its Remix name. -
Copy
.env.exampleto.env:cp .env.example .envIts values point at the local Keycloak from the example repo, which
docker compose -f keycloak/docker-compose.yml up -d --waitstarts from the repository root. That Keycloak'sp2examplesrealm already has a confidential client with the IDremix. In.env.example:KEYCLOAK_ISSUER=http://localhost:8080/auth/realms/p2examplesKEYCLOAK_CLIENT_ID=remixKEYCLOAK_CLIENT_SECRET=remix-local-dev-secretKEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/keycloak/callbackSESSION_SECRET=Set
SESSION_SECRETto a random value, which signs the session cookie.openssl rand -base64 32generates one.To use another Keycloak, set
KEYCLOAK_ISSUERto your realm's issuer URL, andKEYCLOAK_CLIENT_IDandKEYCLOAK_CLIENT_SECRETto the ID and secret of your client. The client needs client authentication on, the standard flow enabled,http://localhost:3000/*as a valid redirect URI, which coversKEYCLOAK_CALLBACK_URL, and+as a valid post logout redirect URI..envis ignored by git, so the secrets stay out of version control. -
Run
pnpm installand thenpnpm dev, with Node 24. The app runs on port 3000. This example leverages remix-auth and its OAuth2 strategy to log users in with the authorization code flow and PKCE. -
Open the
app/auth.server.tsfile. This is a server only file. It sets up the remix-authAuthenticatorwith anOAuth2Strategy, using the environment variables from.env.OAuth2Strategy.discoverreads Keycloak's endpoints from the realm's OpenID configuration, so the issuer URL is the only Keycloak URL the app needs:import { Authenticator } from "remix-auth";import { OAuth2Strategy } from "remix-auth-oauth2";import { requireEnv } from "./env.server";import { decodeJwtPayload } from "./jwt.server";export type SessionUser = {idToken: string;accessTokenClaims: Record<string, unknown>;};async function createAuthenticator() {const authenticator = new Authenticator<SessionUser>();const strategy = await OAuth2Strategy.discover<SessionUser>(requireEnv("KEYCLOAK_ISSUER"),{clientId: requireEnv("KEYCLOAK_CLIENT_ID"),clientSecret: requireEnv("KEYCLOAK_CLIENT_SECRET"),redirectURI: requireEnv("KEYCLOAK_CALLBACK_URL"),scopes: ["openid", "profile", "email"],},async ({ tokens }) => ({idToken: tokens.idToken(),accessTokenClaims: decodeJwtPayload(tokens.accessToken()),}),);authenticator.use(strategy, "keycloak");return authenticator;}After a login, the verify function at the end keeps the ID token and the decoded claims of the access token.
getAuthenticator(), further down in the file, creates the authenticator on first use, so builds don't need the environment variables or Keycloak.remix-auth 4 no longer manages sessions, so the app keeps its own.
app/sessions.server.tsdefines a signed, HTTP-only cookie session keyed bySESSION_SECRET:import { createCookieSessionStorage } from "react-router";import { requireEnv } from "./env.server";type SessionData = {idToken: string;accessTokenClaims: Record<string, unknown>;};export const sessionStorage = createCookieSessionStorage<SessionData>({cookie: {name: "__session",httpOnly: true,sameSite: "lax",path: "/",secure: process.env.NODE_ENV === "production",secrets: [requireEnv("SESSION_SECRET")],maxAge: 60 * 60 * 8,},});export function getSession(request: Request) {return sessionStorage.getSession(request.headers.get("Cookie"));} -
app/routes.tslists the routes. The Log in button posts to/auth/keycloak, whose action callsauthenticator.authenticate("keycloak", request)to redirect you to Keycloak. Keycloak sends you back to/auth/keycloak/callback, which completes the login and stores the session. Inapp/routes/auth.keycloak.callback.tsx:import { redirect } from "react-router";import { getAuthenticator } from "~/auth.server";import { getSession, sessionStorage } from "~/sessions.server";import type { Route } from "./+types/auth.keycloak.callback";export async function loader({ request }: Route.LoaderArgs) {const authenticator = await getAuthenticator();const user = await authenticator.authenticate("keycloak", request);const session = await getSession(request);session.set("idToken", user.idToken);session.set("accessTokenClaims", user.accessTokenClaims);return redirect("/", {headers: { "Set-Cookie": await sessionStorage.commitSession(session) },});}The loader in
app/routes/home.tsxreads the session on the server and returns only decoded claims, so the page's JavaScript never sees a token:export async function loader({ request }: Route.LoaderArgs) {const session = await getSession(request);const idToken = session.get("idToken");if (!idToken) {return { user: null };}return {user: {idTokenClaims: decodeJwtPayload(idToken),accessTokenClaims: session.get("accessTokenClaims") ?? {},},};}View
app/components/user-status.tsxfor how the page renders your state. It shows the Log in button when the loader returns no user, and otherwise your name and email, the Log out button and the decoded tokens. Both buttons are forms that post to the server. The same session check works in any route's loader, so you can use it to secure routes, components, and more. -
The Log out button posts to
/logout. Its action inapp/routes/logout.tsxclears the session and redirects to Keycloak's end-session endpoint, with the ID token asid_token_hint, so your Keycloak session ends too:import { redirect } from "react-router";import { getEndSessionEndpoint } from "~/auth.server";import { requireEnv } from "~/env.server";import { getSession, sessionStorage } from "~/sessions.server";import type { Route } from "./+types/logout";export async function action({ request }: Route.ActionArgs) {const session = await getSession(request);const logoutUrl = new URL(await getEndSessionEndpoint());logoutUrl.searchParams.set("client_id", requireEnv("KEYCLOAK_CLIENT_ID"));logoutUrl.searchParams.set("post_logout_redirect_uri",new URL("/", request.url).href,);const idToken = session.get("idToken");if (idToken) {logoutUrl.searchParams.set("id_token_hint", idToken);}return redirect(logoutUrl.href, {headers: { "Set-Cookie": await sessionStorage.destroySession(session) },});}getEndSessionEndpoint()inapp/auth.server.tsreads the endpoint from the realm's OpenID configuration. -
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 your Keycloak login page.
infoUse
demo/demoon the local Keycloak, or a non-admin user of your realm, to sign in. -
Enter the user's credentials. Click Sign In. You will then be redirected to the application. The Phase Two example landing page now shows your Authenticated state, with your user's name and email and the decoded access and ID tokens.
-
Click Log out. Keycloak ends your session and sends you back to the application, which shows Not authenticated. again. The next Log in asks for your credentials again.