There are three ways into your app — a page, a server action, an API route — and each needs its own check. Guarding one does not guard the others.
Protect a page
Put a middleware.ts in the directory you want to protect:
import { redirect } from '@rsc-kit/core/redirect';
import { currentUser } from '../../auth';
export default async function middleware() {
const user = await currentUser();
if (!user?.isAdmin) redirect('/login');
}It runs before anything in that directory or below it renders. Return nothing to allow; redirect or throw to refuse.
Middleware compose up the tree like layouts — outermost first — and every request runs the whole chain: a full page load, a navigation, a prefetch, a revalidation. There is no flag to remember; the file is the declaration.
Several checks in one directory are a list, run in the order written and stopping at the first refusal — which is how a check written once is reused where it is needed:
import { signedIn, verified } from '../../guards';
import { redirect } from '@rsc-kit/core/redirect';
export default [
signedIn,
verified,
async () => {
if (!(await currentUser())?.isAdmin) redirect('/');
},
];src/app/middleware.ts above it still runs first; the list is this
directory’s own part of the chain.
A guard can also say the page is not there. notFound() in middleware.ts
answers 404 with not-found.tsx, before anything is rendered — for an
admin area that should not admit to existing, rather than sending strangers
somewhere else:
import { notFound } from '@rsc-kit/core/not-found';
export default async function adminsOnly() {
if (!(await currentUser())?.isAdmin) notFound();
}Protect a server action
An action is a public endpoint. Anyone can call it directly:
curl -X POST /_rsc/action \
-H 'X-RSC-Action: 0339292364be#placeOrder' \
-H 'X-RSC-Content-Type: text/plain;charset=UTF-8' \
--data-binary '["a rubber duck"]'That is not a hole to plug — an action is an RPC endpoint and its id is not a secret. It means the check goes inside the action, not in the component that renders the button:
'use server'
import { currentUser } from './auth';
export async function placeOrder(item: string) {
const user = await currentUser();
if (!user) throw new Error('Not signed in');
// …
}Write the check once
One action is fine. Twenty is twenty chances to forget, and the one you forget is the one that matters. Put the check on a client and build every action from it:
'use server'
import { createActionClient } from '@rsc-kit/core/action'
export const client = createActionClient()
.use(async ({ next }) => {
const user = await currentUser()
if (!user) throw new ServerAuthenticationError()
return next({ ctx: { user } })
})'use server'
import { client } from './client'
export const placeOrder = client.input(schema).handler(async ({ input, ctx }) =>
orders.create(ctx.user.id, input),
)
export const listOrders = client.query(async ({ ctx }) => orders.forUser(ctx.user.id))ctx.user is typed and non-null inside the handler, because the only way to get
there was through the middleware that put it in. An action cannot be added
without the check — not because a rule says so, but because there is no other
constructor to reach for.
.handler() makes an action, .query() makes a read, and
both run the same chain. Add a second .use() for a role check and it applies
to everything built from that client:
export const admin = client.use(async ({ ctx, next }) => {
if (!ctx.user.isAdmin) throw new ServerAuthorizationError()
return next({ ctx })
})Authorise on identity, not arguments. cancelOrder(id) that trusts the id
is the whole of an IDOR — the caller chooses the id, so the handler has to check
the row belongs to ctx.user.
Protect an API route
A route runs the same middleware a page in that directory would. Put it under a guarded path and it is guarded:
src/app/admin/
middleware.ts ← guards everything below
page.tsx ← guarded
api/export/route.ts ← guarded tooA refused route answers 401 or 403 rather than redirecting, and names
the destination in X-RSC-Redirect if the middleware wanted one. A fetch
would follow a redirect and hand back a login page as though it were your data.
For a route with no middleware above it, check inside the handler:
import { currentUser } from '../../../auth';
export async function GET(): Promise<Response> {
const user = await currentUser();
if (!user) return new Response('Unauthorized', { status: 401 });
return Response.json(await orders(user.id));
}Read the session
There is no request object. headers() and cookies() read the one in flight:
import { redirect } from '@rsc-kit/core/redirect';
import { cookies, headers } from '@rsc-kit/core/request';
export default async function middleware() {
const jar = await cookies();
const locale = jar.get('locale')?.value ?? negotiate((await headers()).get('accept-language'));
if (!locale) redirect('/en');
}They work anywhere a request is in flight — middleware, server components,
actions, API routes — so whatever you already use for locale, feature flags or
tenants works inside a plain async function. request() gives you the whole
Request for anything the two do not cover.
They are async for a reason worth knowing: at build time there is no request, so a read suspends. React freezes the shell above it and only the part that wanted a header renders per visitor. A synchronous read would force the whole page to re-render for everyone.
Ask once
Middleware wants to know who you are; the layout wants their name; the page
wants their permissions. Wrap the lookup in cache() and that is one query:
import { cache } from '@rsc-kit/core/cache';
export const currentUser = cache(async () => {
const id = await sessionId();
return db.user(id);
});The scope is one request. Two requests never see each other’s answers, and nothing survives between them. Outside a request it just calls through, so shared code does not need to know where it is running.
The page knows what the guard decided
Middleware runs inside the same request, so a page under it that calls
currentUser() gets the cached answer, not a second query — and because the
guard redirected everyone with null, that answer is a user. TypeScript
cannot see the guard, though: the type is still User | null. Put the
guarantee in a helper both sides call:
import { redirect } from '@rsc-kit/core/redirect';
/** The signed-in user. Redirects otherwise, so the return type is `User`. */
export async function requireUser(): Promise<User> {
const user = await currentUser();
if (!user) redirect('/', { search: { auth: true } });
return user;
}export default async function middleware() {
await requireUser();
}export default async function Account() {
const user = await requireUser(); // User, one lookup for the request
return <h1>{user.firstName}</h1>;
}redirect() returns never, which is what narrows the type. And a page
rendered from somewhere the guard does not cover redirects too, instead of
handing a null to a component.
Set a cookie
Middleware runs before the response exists, which makes it the place to put a header or a cookie on it:
import { cookies, responseHeaders } from '@rsc-kit/core/request'
// Middleware runs before anything below it renders, which is also before the
// host has built a response — so this is the one place left where a header or a
// cookie can still be put on it. A component runs after, while the response is
// already streaming, and writing from there throws rather than being dropped.
//
// The page below is frozen at build time and stays frozen: what is written here
// is per request, so neither costs the other anything.
export default async function middleware() {
responseHeaders().set('X-Account-Section', 'yes')
const jar = await cookies()
if (!jar.has('seen-account')) {
jar.set('seen-account', new Date().toISOString(), { httpOnly: true, sameSite: 'lax' })
}
}Actions can write too, which is the case that matters — signing someone in is a mutation that has to leave a cookie behind:
'use server';
import { action } from '@rsc-kit/core/action';
import { cookies } from '@rsc-kit/core/request';
export const login = action.input(credentials).handler(async ({ input }) => {
const session = await authenticate(input);
(await cookies()).set('session', session.token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
});get, set and delete over the request in flight is the whole surface an
auth library needs, so you can wire in whichever one you use.
A guarded page can still be frozen
Whether the content is the same for everyone, and whether you may see it, are different questions. The build answers the first; middleware answers the second, per request. So an internal page whose bytes never vary is frozen at build time and the middleware decides who gets the file:
| Route | Navigation |
|---|---|
| Guarded and frozen — check, then serve from disk | 6.7 ms |
| Guarded, rendered on demand | 2626.7 ms |
| Unguarded, frozen | 0.6 ms |
The check runs before the file is read, so a refusal never touches it.
Where each check goes
| Question | Where it goes |
|---|---|
| May this person see this section? | middleware.ts in its directory |
| May this caller run this action? | Inside the action |
| May this caller use this endpoint? | Inside the route handler |
| Should the page show different things to different people? | The page — it renders per request anyway |