@zautha/nextjs
Zautha for Next.js 16 (App Router): know who is signed in during server rendering, in route handlers and in server actions, and use the prebuilt React components on the client.
Install
Section titled “Install”npm i @zautha/nextjs @zautha/ui.env.local (both keys are on your instance’s API keys page in the dashboard):
NEXT_PUBLIC_ZAUTHA_PUBLISHABLE_KEY=pk_test_...ZAUTHA_SECRET_KEY=sk_test_...# Optional: your own sign-in page (default: the instance's hosted /sign-in)NEXT_PUBLIC_ZAUTHA_SIGN_IN_URL=/sign-in# Production only (see Environment below): every origin your app is served from. Leave it unset while developing# on localhost, or tokens issued for http://localhost:3000 are rejected and every render is signed out.# ZAUTHA_AUTHORIZED_PARTIES=https://app.example.comlocalhost is an allowed origin of every development instance, and your domain and its subdomains are allowed
origins of the production instance; add any other origin your app is served from in Auth settings (Allowed
origins).
proxy.ts
Section titled “proxy.ts”import { createRouteMatcher, zauthaProxy } from "@zautha/nextjs/server";
const isProtected = createRouteMatcher(["/dashboard(.*)"]);
export default zauthaProxy(async (auth, req) => { if (isProtected(req)) await auth.protect();});
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };auth.protect() redirects a signed-out page load to sign-in (with redirect_url) and answers other requests
with 401. createRouteMatcher patterns are regular expressions matched against the whole path. Every route
that calls auth() must be covered by the matcher: auth() throws otherwise.
A handler may return its own response (a redirect, NextResponse.rewrite(), …); the SDK’s cookies are added
to it. The request headers the route sees are set by the proxy: request-header overrides of the handler’s own
NextResponse.next({ request: { headers } }) are not forwarded.
Root layout
Section titled “Root layout”import { Show, SignInButton, UserButton, ZauthaProvider } from "@zautha/nextjs";import "@zautha/ui/styles.css";
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <ZauthaProvider> <header> <Show when="signed-out"> <SignInButton /> </Show> <Show when="signed-in"> <UserButton /> </Show> </header> {children} </ZauthaProvider> </body> </html> );}<ZauthaProvider> reads the signed-in state on the server and hands it to the browser, so the first render
already shows the right state (useAuth() and <Show>; useUser() loads the user in the browser and
reports it once loaded). In server components <Show> is decided on the server; in client components it
is the @zautha/react one. Hooks (useAuth, useUser, …) and components (<SignIn/>, <UserButton/>, …)
are re-exported from @zautha/react.
Social sign-in
Section titled “Social sign-in”<SignIn/> and <SignUp/> show the providers your instance enabled (Google, GitHub, …). Give the provider a page to
come back to and render <AuthenticateWithRedirectCallback/> there (leave it public in proxy.ts):
<SignIn ssoCallbackUrl="/sso-callback" redirectUrl="/dashboard" />;
// app/sso-callback/page.tsximport { AuthenticateWithRedirectCallback } from "@zautha/nextjs";export default function SsoCallback() { return <AuthenticateWithRedirectCallback signInUrl="/sign-in" />;}It finishes the sign-in (or the sign-up, for a provider account no user has yet) and goes on to redirectUrl, where
the server sees the session like after any other sign-in. A second factor or a missing field is asked on that page.
Production instances use your own OAuth apps (dashboard → Auth settings → Social connections); their redirect URL is
https://<your Frontend API host>/v1/oauth_callback.
Server
Section titled “Server”import { auth, currentUser } from "@zautha/nextjs/server";
export default async function Page() { const user = await currentUser(); // Backend API user, fetched once per request; null when signed out return <p>{user?.primary_email_address}</p>;}
export async function GET() { const { userId } = await auth(); if (userId === null) return Response.json({ code: "unauthenticated" }, { status: 401 }); return Response.json({ userId });}auth() returns { userId, sessionId, claims, orgId, orgRole, orgSlug, orgPermissions, getToken(), protect(), redirectToSignIn(), has() }. It verifies the session token the proxy passed on again, so a forged request header never
counts.
Organizations: roles and permissions
Section titled “Organizations: roles and permissions”With an active organization, orgId, orgRole (org:admin), orgSlug and orgPermissions (the role’s custom
permissions, org:invoices:read) come from the session token; all are null without one. has({ role }) and
has({ permission }) take full keys and answer from the token alone, the same in every Zautha SDK; a system permission
(org:sys_…) is never in the token, so it is always false. auth.protect(condition) — { role }, { permission },
{ reverification } or (has) => boolean — on the object auth() returns redirects a signed-out request to sign-in as before and calls notFound()
for a signed-in one that fails it ((await auth()).protect(…)). In the proxy, auth.protect(condition) rewrites a page load to the not-found page
and answers other requests with 404. The rewrite goes to /_zautha/not-found, which no page matches, so Next renders your not-found page; an app
with a root dynamic or catch-all route (app/[...slug]) gets that route’s page there instead (the protected content is still not served). <Show when={{ permission: "org:invoices:read" }}> decides the same way.
import { auth } from "@zautha/nextjs/server";
export default async function InvoicesPage() { const { orgId } = await (await auth()).protect({ permission: "org:invoices:read" }); return <Invoices organization={orgId} />;}Sensitive operations (reverification)
Section titled “Sensitive operations (reverification)”has({ reverification }) tells whether the user verified their identity recently enough: "strict" (10 minutes),
"moderate" (1 hour) and "lax" (1 day) ask a user with two-step verification for their second factor,
"strict_mfa" (10 minutes) for both factors, and { afterMinutes, level? } sets both (1–1440 minutes; level
"first_factor", "second_factor" (the default) or "multi_factor"); a user without two-step verification verifies
the first factor at every level. When not, return reverificationError(requirement): useReverification from
@zautha/nextjs (the same as @zautha/react’s) asks the user to verify that level again and retries.
import { auth, reverificationError } from "@zautha/nextjs/server";
export async function POST() { const { userId, has } = await auth(); if (userId === null) return Response.json({ code: "unauthenticated" }, { status: 401 }); if (!has({ reverification: "strict" })) return reverificationError("strict"); return Response.json({ ok: true });}Impersonation
Section titled “Impersonation”auth().actor (server components, route handlers, server actions, and auth() in the proxy handler) is { sub, iss }
when the request comes from an impersonation session (the session token’s act claim) and null otherwise; the server
<ZauthaProvider> hands it to the browser, so useAuth().actor answers before the SDK loads. The band comes with
<ZauthaProvider>. Such a session never passes has({ reverification }) (its fva is [-1, -1]).
Webhooks
Section titled “Webhooks”import { verifyWebhook, isZauthaWebhookVerificationError } from "@zautha/nextjs/webhooks";
export async function POST(request: Request): Promise<Response> { try { const event = await verifyWebhook(request); if (event.type === "user.created") console.log(event.data.primary_email_address); return new Response(null, { status: 204 }); } catch (error) { if (isZauthaWebhookVerificationError(error)) return new Response(error.code, { status: 400 }); throw error; }}verifyWebhook verifies the request with the endpoint’s signing secret from ZAUTHA_WEBHOOK_SIGNING_SECRET and
returns the event (see Webhooks and @zautha/backend). Keep the route out of auth.protect(): Zautha’s requests carry
no session.
Environment
Section titled “Environment”| Variable | |
|---|---|
NEXT_PUBLIC_ZAUTHA_PUBLISHABLE_KEY |
Required. |
ZAUTHA_SECRET_KEY |
Required for currentUser(). |
NEXT_PUBLIC_ZAUTHA_SIGN_IN_URL |
Optional. Absolute, or a path in your app. |
ZAUTHA_API_URL |
Optional Backend API origin (default https://api.zautha.com). |
ZAUTHA_AUTHORIZED_PARTIES |
Optional, recommended in production. See below. |
ZAUTHA_WEBHOOK_SIGNING_SECRET |
Required for verifyWebhook() (the endpoint’s whsec_…). |
A session token is accepted only when it was issued for an authorized party (its azp). With
ZAUTHA_AUTHORIZED_PARTIES (comma-separated exact origins, e.g. https://app.example.com,https://www.example.com)
those origins are the only authorized parties, and the request’s headers play no part in it; an entry that is
not an origin (a path, a trailing slash, no scheme) throws a ZauthaConfigurationError on every request. List
every origin the app is served from: on an origin missing from the list every request is signed out.
Without it, the authorized party is the request’s origin, read from X-Forwarded-Proto/X-Forwarded-Host (or
Host). A client controls those headers whenever Next is exposed directly or a reverse proxy passes the
client’s values through (nginx’s default), and could then have a token issued for another app of the same
instance (e.g. https://blog.example.com) accepted here. So set ZAUTHA_AUTHORIZED_PARTIES in production, or run
Next only behind a reverse proxy that overwrites both headers with the values the browser used. The
headers still build the URLs the SDK redirects to (sign-in, handshake) either way.