Next.js
A Next.js App Router application can be its own Seamless Auth backend. Two packages do the work:
| Package | Runs in | Gives you |
|---|---|---|
@seamless-auth/nextjs |
Route handlers, server components, proxy.ts |
The /auth routes, server-side session reads, and route protection |
@seamless-auth/react |
Client components | AuthProvider, useAuth(), and the headless client, exactly as in any React app |
With both, there is no separate API server for auth. The browser calls /auth on the
application’s own origin, the session cookies stay first-party, and the server can render the
signed-in user on the first paint.
Requires @seamless-auth/nextjs 0.4.0 or later and @seamless-auth/react 0.14.0 or later
(0.14.0 is the release that made the React SDK safe to render from the App Router). Earlier
@seamless-auth/nextjs releases have no PUT handler and do not serve auth API routes from the
adapter manifest (see Step 1).
browser -> your Next.js app: app/auth/[...seamless]/route.ts (@seamless-auth/nextjs) -> Seamless Auth APIInstall
Section titled “Install”npm install @seamless-auth/nextjs @seamless-auth/reactreact-router-dom is not needed. It is an optional peer of @seamless-auth/react, used only by
the built-in screens, which a Next.js app does not use (see
Sign-In Screens).
Step 1: Serve The Auth Routes
Section titled “Step 1: Serve The Auth Routes”Mount a catch-all route handler at app/auth/[...seamless]/route.ts:
import { createSeamlessAuthHandler } from '@seamless-auth/nextjs'
export const { GET, POST, PUT, PATCH, DELETE } = createSeamlessAuthHandler({ authServerUrl: process.env.AUTH_SERVER_URL!, audience: process.env.AUTH_SERVER_URL!, cookieSecret: process.env.COOKIE_SIGNING_KEY!, serviceSecret: process.env.API_SERVICE_TOKEN!, jwksKid: process.env.JWKS_KID,})This serves the same routes and issues the same cookies as @seamless-auth/express and
@seamless-auth/fastify, for both cookie and bearer transport. Its tests hold it to identical
responses from the Express adapter.
Export all five methods. PUT is new in 0.4.0, and the OAuth provider retirement routes
(PUT /admin/organizations/:id/oauth-providers/:provider/retirement) need it. A route module that
does not export PUT answers those requests with 405 from Next.js before the handler runs.
Routes the handler has no code of its own for are served from the adapter manifest the auth API
publishes at /.well-known/seamless-adapter.json (0.4.0 or later), so a new auth API route works
without a new release of this package. The handler fetches the manifest on its first request,
waits up to five seconds, and keeps it for the life of the process. When the API does not serve
one, it uses the copy bundled with the package and tries again a minute later.
cookieSecretandserviceSecretmust be at least 32 characters, andserviceSecretmust match the auth API’sAPI_SERVICE_TOKEN. Both are checked when the handler is created.- The handler signs cookies with
jsonwebtoken, so it needs the Node.js runtime, which is the default. Do not setexport const runtime = 'edge'.
Options
Section titled “Options”createSeamlessAuthHandler takes the same options as the Express and Fastify adapters, plus
basePath:
| Option | Default | Purpose |
|---|---|---|
authServerUrl |
required | Base URL of your Seamless Auth instance |
audience |
required | Expected aud on the auth server’s tokens. The auth API sets it to its ISSUER, so this is authServerUrl, or the same value as authServerIssuer when that is set |
authServerIssuer |
authServerUrl |
Expected iss on the auth server’s tokens and signed responses, when the auth server advertises an issuer other than the URL you reach it at. Requests still go to authServerUrl |
cookieSecret |
required | Signs the session cookies, 32 characters minimum |
serviceSecret |
required | Shared secret for machine-to-machine calls |
jwksKid |
dev-main |
kid header on the HS256 service tokens the adapter signs for the auth API; not the API’s signing key ID. Warns while unset or dev-main |
basePath |
/auth |
Where the catch-all route is mounted |
cookieDomain |
none | Domain attribute for the auth cookies |
cookieSecure |
true |
Set false only for local HTTP development |
cookieSameSite |
none when secure, else lax |
SameSite policy |
allowedOrigins |
none | Origin allowlist for browsers without Sec-Fetch-Site |
accessCookieName |
seamless-access |
Session cookie name |
registrationCookieName |
seamless-ephemeral |
Registration cookie name |
refreshCookieName |
seamless-refresh |
Refresh cookie name |
preAuthCookieName |
seamless-ephemeral |
Login-initiation cookie name |
messaging |
none | Your own email and SMS delivery for codes and magic links |
resolveClientIp |
none | Picks the end user’s IP to forward to the auth API |
fetchManifest |
true |
Fetch the adapter manifest from the auth API. false uses only the copy bundled with the package, for example in tests that mock fetch |
Client IP. A route handler has no trustworthy source for the client address, because any
caller can set X-Forwarded-For. Nothing is forwarded unless you pass resolveClientIp and read
the header your platform sets and a client cannot, for example x-real-ip on Vercel. A value that
is not an IP address is dropped. The browser’s User-Agent is always forwarded, capped at 512
characters.
Running on the host against the local Docker stack
Section titled “Running on the host against the local Docker stack”The adapter checks the iss of every token and signed response the auth server returns against
authServerIssuer, which defaults to authServerUrl. Inside the seamless init --local Docker
stack the auth server’s ISSUER is http://auth:5312, its compose service name. An app run on the
host with npm run dev reaches the same server at http://localhost:5312, so without help every
sign-in fails with Invalid signed response from Auth Server. Tell the adapter the issuer
separately:
export const { GET, POST, PUT, PATCH, DELETE } = createSeamlessAuthHandler({ authServerUrl: 'http://localhost:5312', authServerIssuer: 'http://auth:5312', audience: 'http://auth:5312', // ...})audience takes the same value because the auth API signs its ISSUER into aud as well, and
audience has no default. The silent refresh on the /auth routes checks the refreshed token
against authServerIssuer too (@seamless-auth/nextjs 0.3.1 or later). Leave authServerIssuer
unset when the URL you call is the auth server’s issuer, as it is on a managed instance.
The Next.js starter reads this from AUTH_SERVER_ISSUER and passes it as both authServerIssuer
and audience; unset or blank, both stay AUTH_SERVER_URL. seamless-cli 0.19.0 or later fills it
in when it scaffolds the starter (see
Environment Variables).
getSeamlessSession also accepts authServerIssuer, so one options object can serve the handler
and the session helpers. The helpers verify only the adapter’s own cookies, so it has no effect
there.
Other differences from Express
Section titled “Other differences from Express”Two behaviors differ from Express because a route handler has no framework underneath: request
bodies are parsed here, exactly as Express’s json() does (only application/json, strict,
100kb maximum), and an unknown route answers 404 { "error": "not_found" } as JSON.
Step 2: Add The Provider
Section titled “Step 2: Add The Provider”Every module in @seamless-auth/react is a client component, so AuthProvider can be rendered
straight from the root layout. Pass the session the server resolved as initialSession:
import { cookies, headers } from 'next/headers'import { getSeamlessSession } from '@seamless-auth/nextjs'import { AuthProvider } from '@seamless-auth/react'
export const dynamic = 'force-dynamic'
export default async function RootLayout({ children }: { children: React.ReactNode }) { // null is signed out. undefined (auth server unreachable) lets the provider find out in the browser. const session = await getSeamlessSession(await cookies(), { authServerUrl: process.env.AUTH_SERVER_URL!, cookieSecret: process.env.COOKIE_SIGNING_KEY!, serviceSecret: process.env.API_SERVICE_TOKEN!, jwksKid: process.env.JWKS_KID, userAgent: (await headers()).get('user-agent') ?? undefined, }).catch(() => undefined)
return ( <html lang="en"> <body> <AuthProvider apiHost="" initialSession={session}> {children} </AuthProvider> </body> </html> )}apiHost=""sends every auth request to/authon the page’s own origin, which is the route handler from step 1.- Catch the error.
getSeamlessSessioncalls the auth API, and it throws when the auth server cannot be reached orcookieSecretis shorter than 32 characters. Uncaught, that renders the error page with a500for every route under the layout. Resolving toundefinedinstead tells the provider the session is unknown: it starts in its loading state and resolves the session from the browser, whilenullrenders signed out. The starter’ssrc/lib/auth.tslogs the error and does the same. initialSessionmakes the first paint render the signed-in user instead of a loading state. The provider then revalidates in the background, without reportingloading, which fills in anything the server did not have and signs out a session that was revoked. It is read once, when the provider creates its session.- Hydration is safe. The first client render matches the server’s:
hasSignedInBeforeisfalseon both and turnstruestraight after hydration when the browser remembers a sign-in. - Props from a server component must be serializable.
apiHost,magicLinkRedirectUri, andinitialSessionare.transportandportstake functions, so set those from a client component that wraps the provider.
How getSeamlessSession reads the session
Section titled “How getSeamlessSession reads the session”It verifies the access cookie locally, then asks the auth API for /users/me with the token
inside it. The result is the /users/me wire type, the same one initialSession takes, so it
passes straight through.
It never refreshes. When the access cookie has expired it returns null, even if the refresh
cookie is still valid. A refresh from the server would rotate the refresh token in a response the
browser never receives, and the browser’s own next refresh would then be refused as a replay,
which revokes the session. Returning null costs nothing: the provider’s background revalidation
renews the session through /auth straight after the page loads.
Server code that needs helpers or types (hasScopedRole, roleGrantsAccess, User) imports them
from @seamless-auth/nextjs or @seamless-auth/client, never from @seamless-auth/react.
Everything exported from @seamless-auth/react is a client reference inside a server component,
including the pure helpers.
Step 3: Protect Pages In proxy.ts
Section titled “Step 3: Protect Pages In proxy.ts”import { NextResponse, type NextRequest } from 'next/server'import { hasSeamlessSession } from '@seamless-auth/nextjs'
export function proxy(request: NextRequest) { if (hasSeamlessSession(request.cookies, { cookieSecret: process.env.COOKIE_SIGNING_KEY! })) { return NextResponse.next() }
const login = new URL('/login', request.url) login.searchParams.set('next', `${request.nextUrl.pathname}${request.nextUrl.search}`) return NextResponse.redirect(login)}
export const config = { matcher: ['/dashboard/:path*'] }hasSeamlessSession checks the cookies locally, with no network call. A valid refresh cookie
counts as a session, because its access cookie may only have expired and the next call to /auth
renews it. Treating that as signed out would send a returning user to the sign-in page once every
access-token lifetime.
proxy.ts is the Next.js 16 name and runs on the Node.js runtime. On Next.js 15.5 or later, use
middleware.ts with export const config = { runtime: 'nodejs' }. The Edge runtime cannot verify
the cookies.
For a role check, getSeamlessClaims returns what the access cookie says about the user, also
locally:
import { getSeamlessClaims, hasScopedRole } from '@seamless-auth/nextjs'
const claims = getSeamlessClaims(request.cookies, { cookieSecret })
if (!claims || !hasScopedRole(claims.roles, 'admin:read')) { return NextResponse.redirect(new URL('/', request.url))}The claims leave out the upstream access token, so they are safe to pass to a client component. Treat them as a routing hint; enforce authorization again in the route handlers that serve data.
Your own API routes
Section titled “Your own API routes”An application route handler checks the session the same way:
import { cookies } from 'next/headers'import { getSeamlessClaims, hasScopedRole } from '@seamless-auth/nextjs'
export async function GET() { const claims = getSeamlessClaims(await cookies(), { cookieSecret: process.env.COOKIE_SIGNING_KEY! })
if (!claims) return Response.json({ error: 'unauthenticated' }, { status: 401 }) if (!hasScopedRole(claims.roles, 'reports:read')) { return Response.json({ error: 'forbidden' }, { status: 403 }) }
return Response.json(await loadReports(claims.id))}From a client component, call it with useAuthorizedFetch(), which sends the session cookies.
Sign-In Screens
Section titled “Sign-In Screens”AuthRoutes, the React SDK’s built-in screens, routes with react-router, which a Next.js app does
not have. Build the screens with the hooks instead: useAuth(), useAuthClient(), and
usePasskeySupport(). How To Build Your Own Auth UI covers the flows.
Points that matter in practice:
- Email codes are six letters, SMS codes are six digits. A code field that keeps only digits rejects every emailed code. Match the input to the channel.
- Magic links land on
/verify-magiclink. The auth API builds the emailed link to that path, so serve a page there that callsverifyMagicLink(token). Verifying does not sign in the tab that opened the link: the session belongs to the browser that asked for it, which collects it fromcheckMagicLink(). When the link is opened in that same browser, callingcheckMagicLink()and thenrefreshSession({ background: true })signs this tab in too. Otherwise, tell the reader to return to the device that asked. - A magic link is single use, and Strict Mode runs effects twice in development. Keep the verification request in a ref so a remount reuses it instead of sending the token again.
- A
429means slow down. The auth server rate-limits code sends and checks. Readerror.statuson the result and say so, instead of reporting a wrong code.
The starter’s src/components/SignIn.tsx and src/components/VerifyMagicLink.tsx implement all of
this.
Admin Console
Section titled “Admin Console”createSeamlessConsoleProxy (@seamless-auth/nextjs 0.3.0 or later) serves the Seamless
admin dashboard from the Next.js app. The dashboard is built to load from
/console on the same origin as /auth, so it calls the cookie-based admin routes without CORS.
Mount the proxy at app/console/[[...path]]/route.ts and export its GET and HEAD:
import { createSeamlessConsoleProxy } from '@seamless-auth/nextjs'
export const { GET, HEAD } = createSeamlessConsoleProxy({ authServerUrl: process.env.AUTH_SERVER_URL!,})It requests the same path under /console on the auth server, which serves the dashboard build
unless SERVE_ADMIN_DASHBOARD=false. Only the method and the path go upstream: no cookies and no
Authorization. The response carries the body and the content-type, cache-control, etag,
and last-modified headers. A path that leaves the console subtree, including an encoded %2f or
%5c, answers 400 without an upstream request, and an unreachable auth server answers 502.
Next.js answers 405 for any other method, because the route exports only GET and HEAD.
| Option | Default | Purpose |
|---|---|---|
authServerUrl |
required | Base URL of the auth server serving the console |
basePath |
/console |
Subtree requested upstream |
mountPath |
/console |
Where the route is mounted, including any Next.js basePath |
The Next.js starter ships this route at src/app/console/[[...path]]/route.ts, gated by
SERVE_ADMIN_CONSOLE, the same switch the Express and Fastify starters use. With anything but
true, /console answers 404 and nothing is requested upstream. seamless init --nextjs --admin=api, the default, writes SERVE_ADMIN_CONSOLE=true and serves the console at
http://localhost:5173/console, starting with seamless-cli 0.20.0 (fells-code/seamless-cli#239). The auth server needs
SERVE_ADMIN_DASHBOARD=true, and the app’s origin must be in its ORIGINS for passkey ceremonies
started in the console (the local stack already allows http://localhost:5173).
Set mountPath when the route lives somewhere other than app/console, or when next.config.ts
sets a basePath: with basePath: '/app', pass mountPath: '/app/console'. Only the same-origin
shape is supported; a dashboard on another origin cannot call the route handler, which sends no
CORS headers.
Deploying
Section titled “Deploying”- Run the app on the Node.js runtime with the environment variables above set at runtime.
output: 'standalone'innext.config.tsproduces a self-contained server; the starter’sDockerfilepackages it on port 80 with a/healthroute.- Add the application’s origin to the auth server’s
ORIGINSso passkey ceremonies verify.