npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.dev host. This is staging — the only environment that exists today. There is no production service yet; the plainauth.app cutover 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-app

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

  1. Auth URLhttps://<project>.plainauth.kedalen.dev
  2. Publishable keypk_…
  3. Secret keysk_… (server-only)

Then, on the same page, add two entries:

  1. http://localhost:3000 to Trusted origins, and http://localhost:3000/api/auth/handoff/callback to 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 GET that works proves nothing. Without the exact redirect URI, the handoff 403s after a successful sign-in, which reads as "it half worked". Why →

Step 3 — Install

npm install @plainauth/nextjs

or 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 HOME so 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/callback

Only 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 dev

Visit /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:

  1. 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.
  2. A create-plainauth-app / npx @plainauth/nextjs init scaffolder 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.
  3. 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/nextjs and @plainauth/nextjs/server are separate export specifiers with no import edge between them; the server entry calls assertServerRuntime() 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 tsc over 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 the bash blocks.
  • Your build needs no secrets. Nothing in this package reads the environment at import, so next build never needs PLAINAUTH_SECRET_KEY (or any of the other three). One check evaluates the snippets' module scope with a completely empty Plainauth environment; another runs a real next build over 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.