Skip to content

@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.

Terminal window
npm i @zautha/nextjs @zautha/ui

.env.local (both keys are on your instance’s API keys page in the dashboard):

Terminal window
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.com

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

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.

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.

<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):

app/sign-in/page.tsx
<SignIn ssoCallbackUrl="/sso-callback" redirectUrl="/dashboard" />;
// app/sso-callback/page.tsx
import { 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.

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.

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} />;
}

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 });
}

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

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

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.