Next.js
Keep the session in an encrypted cookie with the Next.js App Router, where Server Components read it and Proxy refreshes it.
In this guide, you keep the session in an encrypted cookie with the Next.js App Router. Server Functions sign in and out, Proxy refreshes the tokens, and Server Components read the session. The first response already shows the signed-in page, so the browser never shows a "Checking session…" state or a flash of signed-out content. Client Components get a small client that mirrors the session, and the hooks work with it. Server rendering explains how the pieces fit.
A complete app is in
examples/next-app.
Before you begin
- Next.js 16, with the App Router.
- A secret of 32 or more random characters to seal the cookie, set as
SESSION_SECRETon the server. To make one, runopenssl rand -base64 32.next builddoesn't need it.
Installation
To install the packages, run the following command:
npm install @monarcode/session-kit next zod1. Create the server engine
createServerAuth from @monarcode/session-kit/server keeps the session in
a cookie sealed with your secret, and refreshes it with your backend. Create
it in a server-only module, so Next refuses to bundle it for the browser:
import "server-only";
import { } from "@monarcode/session-kit/server";
import { } from "zod";
const = "https://api.example.com";
const = .({ : .(), : .() });
const = .({ : .(), : .() });
export const = ({
: "app",
: ,
: ..!,
: async ({ , }) => {
const = await (`${}/auth/refresh`, {
: "POST",
,
: { "Content-Type": "application/json" },
: .({ }),
});
// The backend rejected the refresh token: sign out.
if (. === 401) return null;
if (!.) throw new (`Refresh failed (${.})`);
return .(await .());
},
});
/** Signs in with your backend, from the server. */
export async function (: string, : string) {
const = await (`${}/auth/login`, {
: "POST",
: { "Content-Type": "application/json" },
: .({ , }),
});
const : unknown = await .().(() => null);
if (!.) {
// The backend says why, such as wrong credentials, in `message`.
const = .({ : .() }).();
throw new (
.
? ..
: `Sign-in failed (${.})`,
);
}
return { ....(), : .() };
}Server sessions covers its other options, rotating the secret, signing out, and the limits every setup shares.
2. Read the session in Server Components
Every serverAuth method takes a cookie jar. nextJar(await cookies())
from @monarcode/session-kit/next gives one over Next's cookies, read-only
by default, as Server Components can't set cookies. A data access layer
reads the session once per render:
import "server-only";
import { } from "react";
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/next";
import { } from "next/headers";
import { } from "next/navigation";
import { } from "./server";
export const = (async () =>
.((await ())),
);
/** The session, or the sign-in page, which sends the user back to `path`. */
export async function (: string) {
const = await ();
if (!)
(`/login?redirectTo=${(())}`);
return ;
}getSession resolves { sessionId, user }, never tokens, so it's safe to
pass to Client Components.
3. Refresh in Proxy
A read-only jar can't save refreshed tokens, so Proxy refreshes access that
is about to expire before any page or Server Function reads it.
proxyJar(request, response) sets the new cookie on the response, and Next
passes it on to the Server Components rendering the same request:
import { } from "@monarcode/session-kit/next";
import { , type } from "next/server";
import { } from "./lib/auth/server";
export async function (: ) {
const = .();
// If the refresh fails, the page still renders with the cookie as it is.
await .((, )).(() => {});
return ;
}
export const = {
: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Server Functions are POST requests to the page that calls them, so keep the matcher wide enough to include those pages.
4. Add Server Functions
Server Functions set cookies, so they pass { writable: true }. They decide
what the browser may ask for:
"use server";
import { } from "@monarcode/session-kit/next";
import { } from "next/headers";
import { } from "zod";
import { , } from "../lib/auth/server";
async function () {
return (await (), { : true });
}
export async function () {
return .(await ());
}
const = .({ : .(), : .() });
export async function (: unknown) {
const { , } = .();
try {
return {
: await .(
await (),
await (, ),
),
};
} catch () {
return {
: instanceof ? . : "Sign-in failed",
};
}
}
export async function () {
await .(await ());
}signInAction returns its failure rather than throwing it, as Next advises
for expected errors: in production, Next hides a thrown error's message from
the browser.
5. Create the browser client
createMirrorAuth makes the client that Client Components use, from the
session the layout read and your Server Functions:
import { } from "@monarcode/session-kit";
import {
,
,
,
} from "../../app/actions";
export type = <
<<typeof >>
>;
export function (: | null) {
return ({
: "app",
,
: ,
: async (: { : string; : string }) => {
const = await ();
if ("error" in ) throw new (.);
return .;
},
: ,
});
}
export type = <typeof >;Create the hooks, typed by that client:
import { } from "@monarcode/session-kit/react";
import type { } from "../lib/auth/client";
export const { , , } =
<>();useMirrorAuth from @monarcode/session-kit/react creates the client in a
Client Component, once per request on the server, never at module level. It
keeps the client and the server in step both ways, rendering Server
Components again with router.refresh() when the session changes in the
browser:
"use client";
import type { } from "react";
import { } from "@monarcode/session-kit/react";
import { } from "next/navigation";
import { , type } from "../lib/auth/client";
import { } from "./hooks";
export function (: {
: | null;
: ;
}) {
const = ();
const = (, ., () =>
.(),
);
return < ={}>{.}</>;
}Render the providers in the root layout, with the session it reads:
import type { } from "react";
import { } from "../lib/auth/session";
import { } from "./providers";
export default async function (: { : }) {
const = await ();
return (
< ="en">
<>
< ={}>{.}</>
</>
</>
);
}6. Protect pages
Check the session in each page, or in the data access layer, rather than in a layout. Layouts don't render again on navigation:
import { } from "../lib/auth/session";
export default async function () {
const = await ("/");
return <>Welcome, {..}</>;
}The sign-in page sends a signed-in user back to redirectTo, and its form
calls the client's signIn. When the session changes in the browser, after a
sign-in or sign-out here or in another tab, useMirrorAuth renders the page
again on the server, so these redirects run without code in the form:
import { } from "@monarcode/session-kit";
import { } from "next/navigation";
import { } from "../../lib/auth/session";
import { } from "./sign-in-form";
export default async function ({
,
}: {
: <<string, string | string[] | undefined>>;
}) {
if (await ())
(((await ).redirectTo));
return < />;
}The form is a Client Component that calls the client's signIn:
"use client";
import { , type } from "react";
import { } from "../hooks";
export function () {
const = ();
const [, ] = ("");
async function (: <>) {
.();
const = new (.);
try {
await .({
: (.("username")),
: (.("password")),
});
} catch () {
// An AuthError; its cause is what signInAction returned.
const = instanceof ? . : ;
( instanceof ? . : "Sign-in failed");
}
}
return (
< ={}>
<>
Username
< ="username" ="username" />
</>
<>
Password
<
="password"
="password"
="current-password"
/>
</>
{ && < ="alert">{}</>}
<>Sign in</>
</>
);
}In this guide, the server signs in with your backend, so the user in the cookie
comes from the backend. If the browser signs in with the backend itself, have
signInAction take the tokens and the user instead. It seals what it's given,
as Sign in on the TanStack Start page
explains: check the tokens there first, or let your backend authorize requests
with the access token.
Call your API
In Server Components, get the access token with a read-only jar. Proxy
refreshed it before the render. A token that expired anyway rejects with
REFRESH_FAILED, since the jar can't save a new one:
import { } from "@monarcode/session-kit/next";
import { } from "next/headers";
import { } from "zod";
import { } from "../../lib/auth/server";
import { } from "../../lib/auth/session";
export default async function () {
await ("/orders");
const = await ..(
(await ()),
);
// requireSession has checked that someone is signed in.
const = await ("https://api.example.com/orders", {
: { : `Bearer ${!.}` },
});
if (!.) throw new (`Orders failed (${.})`);
const =
.(.({ : .() }))
.(await .());
return <>{.} orders</>;
}In Server Functions and Route Handlers, a writable jar can also renew
after a 401.
To call the API from the browser too, with createAuthFetch or effetch's
sessionBearer, opt in with clientCredentials: true in createServerAuth.
Expose serverAuth.clientCredentials.get and .renew through a Route
Handler, and pass them as the client's credentials. A Route Handler rather
than a Server Function, because Next dispatches Server Functions one at a
time and renders the page again after one sets a cookie. Next doesn't check
where a Route Handler's requests come from, so accept your own site only:
import { } from "@monarcode/session-kit/next";
import { } from "next/headers";
import { } from "zod";
import { } from "../../../../lib/auth/server";
const = .({ : .(), : .() });
export async function (: ) {
if (..("sec-fetch-site") !== "same-origin")
return new (null, { : 403 });
const = (await (), { : true });
const = .(await .().(() => null));
return .(
.
? await ..(, .)
: await ..(),
);
}The browser keeps the access token in memory, never the refresh token; see Server sessions. The example app does this.
Things to know
- Signing out ends the session only with
revoke. A copy of the cookie works again ten seconds after sign-out: see Sign-out. - Writable jars only where Next allows writes. Pass
{ writable: true }in Server Functions and Route Handlers only. In a Server Component, a writable jar would refresh and then fail to save the new tokens, which spends a rotating refresh token. - One jar per request.
nextJarreturns the same jar for the same store andwritable, and the read-only jar follows the writable one.proxyJarreturns the same jar for the same response. - Separate instances. Next builds Proxy, Route Handlers, and pages with
their Server Functions separately, so each has its own
serverAuth, even on one server: they share no refresh. A matcher that covers every route that reads the session lets Proxy refresh first, so Route Handlers and Server Functions rarely need to. - Rendering. Reading cookies makes a page dynamic. Next sends such pages
with
Cache-Control: no-store, and plainuse cachecan't read cookies. Keep pages that read the session on the Node.js runtime, the default: Next may not pass Proxy's refreshed cookie on to Edge runtime pages. - Tabs. Signing in or out in one tab reaches the others, which ask the server again and render their pages again. So does returning to a tab after a while.
- More. Several server instances, the cookie's size, and plain http during development: see Server sessions.
Next steps
- Server sessions: the cookie, signing out, and the limits every server-rendered setup shares.
- Using the client: what the browser client can do.
- Effect on the server: run Effects with a request's session.