@plainauth/nextjs
v0.1.2
Published
Next.js App Router proxy, route handler and server session helpers for plainauth.
Readme
@plainauth/nextjs
Middleware (Next 16: proxy) and server session helpers for Next.js App Router.
Source and issue reporting: github.com/uripg/plainauth.
Our onboarding goal is simple: a stranger goes from zero to a working sign-in in a fresh Next.js app in under 5 minutes. The path below is complete — nothing is elided behind "and then configure your provider".
The path is steps only. Every why — and there is a lot of it, because most of this is security-shaped — lives in Why it is shaped this way, one anchor per step. Follow the path; read the reasoning when a step surprises you, or afterwards, or never. It is not needed to finish.
Where things are. The dashboard is at https://dashboard.plainauth.kedalen.dev and your project's auth URL is a
*.plainauth.kedalen.devhost. This is staging — the only environment that exists today. There is no production service yet; theplainauth.appcutover requires explicit sign-off.The hosted sign-in and sign-up pages are available in staging. Treat staging projects and credentials as disposable: availability and stored data are not covered by a production commitment.
Installation
Step 1 — Have a Next.js app
npx create-next-app@latest my-app --typescript --app
cd my-appSkip if you already have one. App Router required.
Step 2 — Create a project in the dashboard, and copy four things
Open https://dashboard.plainauth.kedalen.dev and create a project. From its page, copy:
- Auth URL —
https://<project>.plainauth.kedalen.dev - Publishable key —
pk_… - Secret key —
sk_…(server-only)
Then, on the same page, add two entries:
http://localhost:3000to Trusted origins, andhttp://localhost:3000/api/auth/handoff/callbackto Handoff redirect URIs.
⚠ Step 2.4 is not optional, and it is the single most likely thing to go wrong. Without the trusted origin, every sign-in POST is refused — and a
GETthat works proves nothing. Without the exact redirect URI, the handoff403s after a successful sign-in, which reads as "it half worked". Why →
Step 3 — Install
npm install @plainauth/nextjsor pnpm add @plainauth/nextjs — one line either way. @plainauth/core and
@plainauth/shared come down as dependencies; you do not name them.
With pnpm 11, a release under 24 hours old may add version exceptions to
pnpm-workspace.yaml; an explicitly configured release-age policy instead prompts once in a terminal or fails non-interactively, so wait 26 hours after a Plainauth release before a timed setup run.
The published 0.1.0 release was verified from outside this repository on 2026-08-14 under both npm and pnpm, with a clean
HOMEso no local registry config could make it pass by accident. The 0.1.2 tarballs were installed, imported, type-checked, and style-compiled the same way on 2026-08-16. MIT licensed.If you use
@plainauth/ui, add it too — and its stylesheet import is not optional: see the UI package on npm.
Step 4 — .env.local
Runtime values. PLAINAUTH_SECRET_KEY is consumed only by lib/plainauth.ts for the server-to-server handoff exchange.
# .env.local — Plainauth
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_PLAINAUTH_PUBLISHABLE_KEY=pk_...
PLAINAUTH_URL=https://<project>.plainauth.kedalen.dev
PLAINAUTH_SECRET_KEY=sk_...
PLAINAUTH_HANDOFF_REDIRECT_URI=http://localhost:3000/api/auth/handoff/callbackOnly the publishable key is NEXT_PUBLIC_. The secret is consumed by the handoff helper
below and must never gain that prefix.
Why →
Step 5 — The auth mount point
Mount every ordinary Plainauth API route on your origin. The callback file below is more specific and Next.js routes it first.
// app/api/auth/[...all]/route.ts
import { configFromEnv, createAuthRouteHandler } from "@plainauth/nextjs/server";
export const { GET, POST } = createAuthRouteHandler(configFromEnv);Pass configFromEnv, not configFromEnv(): Next evaluates route modules during builds,
and a build must not demand runtime configuration.
Why →
Step 6 — Route protection
Protect dashboard navigation on Next.js 16+. On Next.js 15, name this middleware.ts and export middleware instead.
// proxy.ts
import { configFromEnv, createAuthProxy } from "@plainauth/nextjs/server";
export const proxy = createAuthProxy(configFromEnv, { protect: ["/dashboard"] });
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};The proxy improves navigation; the protected page remains the security boundary. Why →
Step 7 — The hosted sign-in and sign-up round trip
Read and parse the handoff configuration per request. This is where PLAINAUTH_SECRET_KEY is consumed; configFromEnv deliberately does not read it.
// lib/plainauth.ts
import { asPublishableKey } from "@plainauth/nextjs";
import { asSecretKey, type HandoffConfig } from "@plainauth/nextjs/server";
/** Every value read here, and nothing read until this is called. */
export function handoffConfigFromEnv(): HandoffConfig {
const url = required("PLAINAUTH_URL", process.env.PLAINAUTH_URL);
const pk = required(
"NEXT_PUBLIC_PLAINAUTH_PUBLISHABLE_KEY",
process.env.NEXT_PUBLIC_PLAINAUTH_PUBLISHABLE_KEY,
);
const sk = required("PLAINAUTH_SECRET_KEY", process.env.PLAINAUTH_SECRET_KEY);
const uri = required("PLAINAUTH_HANDOFF_REDIRECT_URI", process.env.PLAINAUTH_HANDOFF_REDIRECT_URI);
return {
baseUrl: url,
publishableKey: asPublishableKey(pk),
secretKey: asSecretKey(sk),
redirectUri: uri,
};
}
function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`${name} is not set. Copy it from your project's dashboard page.`);
return value;
}Start the hosted "sign-in" page explicitly.
// app/sign-in/route.ts
import { createSignInHandler } from "@plainauth/nextjs/server";
import { handoffConfigFromEnv } from "@/lib/plainauth";
export const GET = createSignInHandler(handoffConfigFromEnv, { kind: "sign-in" });Start the hosted "sign-up" page. These are the two HostedPageKind values.
// app/sign-up/route.ts
import { createSignInHandler } from "@plainauth/nextjs/server";
import { handoffConfigFromEnv } from "@/lib/plainauth";
export const GET = createSignInHandler(handoffConfigFromEnv, { kind: "sign-up" });Finish the state-bound handoff. This more-specific route must exist alongside and shadow app/api/auth/[...all]/route.ts.
// app/api/auth/handoff/callback/route.ts
import { createHandoffCallbackHandler } from "@plainauth/nextjs/server";
import { handoffConfigFromEnv } from "@/lib/plainauth";
export const GET = createHandoffCallbackHandler(handoffConfigFromEnv, {
successPath: "/dashboard",
});The only valid hosted page kinds are "sign-in" and "sign-up". Both initiation
routes depend on the callback. Keep the callback as its own, more-specific file alongside
the catch-all; Next.js routes it before [...all].
Why hosted →
Why two handlers →
Step 8 — A protected page
Enforce the session on the server; proxy.ts is only the navigation convenience.
// app/dashboard/page.tsx
import { configFromEnv, createServerAuth } from "@plainauth/nextjs/server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export default async function Dashboard() {
const cookieStore = await cookies();
const session = await createServerAuth(configFromEnv).getSession(cookieStore);
if (!session) redirect("/sign-in");
return <p>Signed in as {session.user.email}</p>;
}Keep await cookies() on its own line before reading config. That is what tells Next this
page is request-rendered instead of statically prerendered during next build.
Why →
Step 9 — Run it
npm run devVisit /dashboard → redirected to /sign-in → sign up or sign in → back on /dashboard
with your email on screen.
Why it is shaped this way
Nothing below is needed to finish the path. It is here because every one of these decisions has a failure mode that looks like something else when you meet it cold.
Why Step 2.4 is the cliff
Better Auth's CSRF check compares the request's Origin against the project's pinned auth
URL plus its trusted origins. Your app's origin is neither until you add it, so every
sign-in POST is refused while it is missing — and because the check is method-scoped, a
GET that works proves nothing. Add http://localhost:3000 now and your production origin
when you deploy.
The handoff redirect URI is compared EXACTLY, character for character, against your
project's declared list. Not a prefix, not an origin, not a wildcard — every looser rule has
a known bypass, and an open redirector on this path is an account-takeover primitive.
Declare the same string you put in PLAINAUTH_HANDOFF_REDIRECT_URI.
Neither of these costs time; both are cliffs. Miss the trusted origin and every sign-in POST
is refused with a message that says nothing about origins. Miss the redirect URI and the
handoff refuses with 403 after a successful sign-in. Both end in a support conversation
rather than at 6 minutes, which is why the hosted page's unknown-key screen names the
problem in words.
Why you pass configFromEnv and not configFromEnv()
next build imports every route file while it collects page data. Whatever sits at a
route module's top level therefore runs inside your build, not inside a request. So this
spelling — which reads better, and which this README shipped until it was tried on a real
deploy —
export const GET = createSignInHandler(handoffConfigFromEnv()); // ✗reads PLAINAUTH_SECRET_KEY during next build. Your machine has .env.local and it
works. Your CI does not, and the build does not warn — it fails:
Error: Failed to collect page data for /sign-up
[cause]: PLAINAUTH_SECRET_KEY is not set.The failure is the good outcome. The fixes are the bad one, and that is the real reason this package takes a function. There are only two ways past that error, and both are worse than the error:
- Put the real
sk_…in the build environment. It is now in your CI configuration, in your build logs, and in every system that snapshots a build environment — a credential that grants session exchange for your whole project, sitting somewhere it is never used. - Build against a placeholder. The build passes and ships a bundle whose exchange leg was compiled against a credential that is not the deployment's.
Passing the function removes the choice: nothing reads the environment until a request
arrives, so a build needs no secrets at all and your deployment reads exactly the values it
runs with. This is not a Next.js quirk to work around — it is the general rule that
runtime credentials do not belong to a build, and the () is what smuggles them in.
If you factor differently, keep the rule rather than the spelling: the environment must be
read inside the request, not at any module's top level. const config = handoffConfigFromEnv();
export const GET = createSignInHandler(() => config) looks lazy and is not.
Why only one variable is NEXT_PUBLIC_
PLAINAUTH_SECRET_KEY is read by @plainauth/nextjs/server and by nothing else. The
browser-facing config type declares secretKey?: never, so putting one there is a compile
error that names the field. Secret keys must never reach the browser. The key therefore
never appears in a NEXT_PUBLIC_* variable, is never imported from a "use client" module,
and @plainauth/nextjs's browser entry point cannot even name it — see
What this package guarantees.
The publishable key is NEXT_PUBLIC_ because it identifies a tenant and authenticates
nobody, and the same variable then serves the client entry point.
Why the auth surface mounts on your own origin
Step 5 mounts the auth surface on your origin and forwards to plainauth
server-to-server. That is what makes the session cookie land on your domain — where
await cookies() can read it — and it is why you will not meet a CORS error. The handler
only forwards paths plainauth actually declares; anything else it 404s without sending.
Why the proxy is not the boundary
Next.js's own docs: "Always verify authentication and authorization inside each Server
Function rather than relying on Proxy alone." The proxy is a redirect for signed-out
visitors. Keep the getSession/requireSession call in the page, as Step 8 does.
Why sign-in is hosted on our origin
Turnstile's widget and hostname limits mean that a challenge rendered on your origin would cap the platform at 20 customers; rendered on ours it needs no per-customer provisioning at all.
Why two handlers rather than a documented redirect
The session is established on our origin, so a cookie there is no use to you — the
callback exchanges a single-use, 30-second code for a session cookie it sets as your own
first-party cookie. And createSignInHandler sets a __Host- state cookie that the
callback then requires: without it, an attacker can cause your app to adopt their session
(login CSRF). Neither handler can be used without the other half of that binding, which is
exactly why they are functions and not a snippet.
Honest step count
Nine steps, four files, four environment variables, one dashboard visit.
The path is walkable as of 2026-08-14 — the number is still not measured.
No timed run is quoted here today, on purpose. The previous edition of this section quoted ≈5¾ minutes. That number was measured against a path no customer could complete: Step 3 did not install, so the honest measurement was "cannot start", and quoting 5¾ would have been precise about the wrong thing.
Step 3 now installs — the packages are published and the install is verified from outside this repository under both package managers. That removes the wall; it does not produce a number. The next measurement must be a timed run by a person who has not seen the product, and it must state a human figure rather than an automated agent's. The two are not the same reading: an agent that greps a 250-line document for the next command is not doing what a person does, and the one zero-context proxy run we have puts a real human on a working path at 10–15 minutes, not 5¾ — most of the gap being time spent reading, not typing. Splitting the rationale out of the path (this edition) is the change aimed at that gap, and it is exactly the kind of change that has to be measured rather than assumed to have worked.
What is known about where the time goes, independent of the total:
- The dashboard is the largest single step — four values, two of which (trusted
origins, handoff redirect URIs) are lists a person has to find. The dashboard now presents
all four as part of project creation and offers a copy-paste
.env.local. - A
create-plainauth-app/npx @plainauth/nextjs initscaffolder would write Steps 5–8 and paste the env file, leaving the dashboard visit as the only human work. With (1), that is a genuinely sub-3-minute run. - The handoff gave back about half of what deleting the sign-in form saved. It needs a second dashboard value (the secret key), a third (the declared redirect URI), two more environment variables, and a second route file. The form is gone; the round trip is not free.
The scaffolder is not in this package. A timed run still needs a real stranger; the onboarding claim does not hold until that run says otherwise.
What this package guarantees
- No secret key can reach a client bundle.
@plainauth/nextjsand@plainauth/nextjs/serverare separate export specifiers with no import edge between them; the server entry callsassertServerRuntime()on import; and an automated bundle check covers every declared browser export, with negative controls, and refuses any server module or secret-key function in the output. - Session reads are authoritative, every time. No cache, no memoisation, no
React.cache(). A session revoked in the dashboard is refused on the next navigation immediately. Introducing a cache here would weaken that guarantee, not merely optimize it. - Every TypeScript snippet above compiles as pasted. An automated check extracts the
snippets, assembles them into a Next-app-shaped tree, and runs
tscover it. It is in the release gate, so a snippet that stops compiling fails the build rather than the reader. It checks compilation only — not runtime behaviour, and not thebashblocks. - Your build needs no secrets. Nothing in this package reads the environment at import,
so
next buildnever needsPLAINAUTH_SECRET_KEY(or any of the other three). One check evaluates the snippets' module scope with a completely empty Plainauth environment; another runs a realnext buildover the same assembled app. The second catches build behaviour that a module-scope check cannot see.
API
@plainauth/nextjs — client-safe
| | |
|---|---|
| createNextBrowserClient({ appUrl, publishableKey }) | Browser client pointed at your app's own mount point. |
| asPublishableKey(value) | Parse a pk_… from the environment. Throws by name on an sk_…. |
| isPlainauthApiError, PlainauthApiError, ChallengeRequiredError, ChallengeUnavailableError, RateLimitedError, Remedy | Refusals, and what to do about them. |
@plainauth/nextjs/server — server-only
| | |
|---|---|
| configFromEnv(overrides?) | Reads PLAINAUTH_URL + NEXT_PUBLIC_PLAINAUTH_PUBLISHABLE_KEY. |
| createAuthRouteHandler(config) | { GET, POST, DELETE } for app/api/auth/[...all]/route.ts. |
| createAuthProxy(config, { protect, publicPaths?, sessionCheck? }) | Route protection. createAuthMiddleware is the Next 15 alias. |
| createServerAuth(config) | { getSession, requireSession } for RSC and Route Handlers. |
| createSignInHandler(handoffConfig) | GET for app/sign-in/route.ts; sends the user to the hosted page and sets the __Host- state cookie. |
| createHandoffCallbackHandler(handoffConfig, { successPath? }) | GET for your declared redirect URI; exchanges the code for your own first-party session cookie. |
| SessionRequiredError | Thrown by requireSession; carries .signInUrl. |
sessionCheck defaults to "authoritative". "cookie-presence" skips the round trip and
admits a revoked session — it is a redirect for signed-out visitors and nothing more,
safe only because the page behind it calls getSession anyway.
