Skip to Content
Setups

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_SECRET on the server. To make one, run openssl rand -base64 32. next build doesn't need it.

Installation

To install the packages, run the following command:

npm install @monarcode/session-kit next zod

1. 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:

lib/auth/server.ts
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:

lib/auth/session.ts
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:

proxy.ts
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:

app/actions.ts
"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:

lib/auth/client.ts
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:

app/hooks.ts
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:

app/providers.tsx
"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:

app/layout.tsx
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:

app/page.tsx
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:

app/login/page.tsx
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:

app/login/sign-in-form.tsx
"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:

app/orders/page.tsx
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:

app/api/auth/credentials/route.ts
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. nextJar returns the same jar for the same store and writable, and the read-only jar follows the writable one. proxyJar returns 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 plain use cache can'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

On this page