Skip to content

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 API

Terminal window
npm install @seamless-auth/nextjs @seamless-auth/react

react-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).


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.

  • cookieSecret and serviceSecret must be at least 32 characters, and serviceSecret must match the auth API’s API_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 set export const runtime = 'edge'.

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.

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.


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:

app/layout.tsx
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 /auth on the page’s own origin, which is the route handler from step 1.
  • Catch the error. getSeamlessSession calls the auth API, and it throws when the auth server cannot be reached or cookieSecret is shorter than 32 characters. Uncaught, that renders the error page with a 500 for every route under the layout. Resolving to undefined instead tells the provider the session is unknown: it starts in its loading state and resolves the session from the browser, while null renders signed out. The starter’s src/lib/auth.ts logs the error and does the same.
  • initialSession makes the first paint render the signed-in user instead of a loading state. The provider then revalidates in the background, without reporting loading, 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: hasSignedInBefore is false on both and turns true straight after hydration when the browser remembers a sign-in.
  • Props from a server component must be serializable. apiHost, magicLinkRedirectUri, and initialSession are. transport and ports take functions, so set those from a client component that wraps the provider.

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.


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.

An application route handler checks the session the same way:

app/api/reports/route.ts
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.


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 calls verifyMagicLink(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 from checkMagicLink(). When the link is opened in that same browser, calling checkMagicLink() and then refreshSession({ 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 429 means slow down. The auth server rate-limits code sends and checks. Read error.status on 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.


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:

app/console/[[...path]]/route.ts
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.


  • Run the app on the Node.js runtime with the environment variables above set at runtime.
  • output: 'standalone' in next.config.ts produces a self-contained server; the starter’s Dockerfile packages it on port 80 with a /health route.
  • Add the application’s origin to the auth server’s ORIGINS so passkey ceremonies verify.