The first version of a signed-in area in the App Router usually looks like this:
// app/(app)/layout.tsx
export default async function AppLayout({ children }) {
const user = await getUser();
if (!user) redirect("/sign-in");
return <Shell user={user}>{children}</Shell>;
}
One check, at the top, covering every page in the group. It reads like a guard around everything below it. It is not.
Why the layout check leaks
Layouts in the App Router are shared between the pages under them. When
someone clicks from /dashboard to /projects, Next.js fetches the new page
and keeps the layout it already rendered. The layout function does not run
again. That is what makes navigation fast, and the Next.js authentication
guide says it plainly: layouts "don't re-render on navigation, meaning the
user session won't be checked on every route change".
There is a second, quieter reason. A layout does not decide whether the page under it renders. The router renders the segments, so even on a first load the page's code runs alongside the layout's, and a redirect thrown in the layout does not stop the page's queries from running.
So picture a session that ends while the tab is open: it expires, an admin
revokes it, or the person signs out in another tab. The next click renders
/projects on the server, and the only check between that page and your
data is the one the layout did minutes ago. The page queries the database
for a user who is no longer signed in.
It gets worse with a page that does its own lookup by id from the URL. If
the page assumes "the layout checked, so there is a user", and then reads a
record by params.id without checking who owns it, you have an
authorisation hole that only shows up on client navigation. It never shows
up in a test that loads the page directly.
Three layers, three jobs
1. The proxy (proxy.ts, once called middleware): the fast path. It runs
before rendering, so a signed-out visitor to /dashboard gets a plain 307 to
sign-in instead of a page that starts streaming and then redirects. On most
setups it can only see that a session cookie exists, not whether the session
is still valid: it runs on a lightweight runtime without your database.
Treat it as a convenience. Deleting it must not make anything unprotected.
2. The layout: for the shell. The shell needs the user to show a name and an avatar, so it reads the session anyway. Let it redirect when there is none: that covers the first load of any page in the group. Do not treat it as the check for the pages under it.
3. The page: the real check. Every page that shows private data reads the session itself:
export default async function ProjectsPage() {
const user = await requireUser("/projects");
const projects = await listProjects(user.id);
// ...
}
A page renders on every navigation, including client-side ones, so this check always runs. Same for every server action and route handler: each is a public endpoint that anyone can call without clicking your UI.
"But that is the same query three times"
It is not, if the session read is wrapped in React's cache:
export const getSession = cache(async () => auth.api.getSession({ headers: await headers() }));
cache memoises per request. The layout and the page render in the same
request, so the second call returns the first call's result. Three layers cost
one lookup.
Return paths
A redirect to sign-in should bring the person back where they were. The
page knows its own path, so it passes it: requireUser("/projects"). The
layout does not know the URL (layouts get no pathname), so have the proxy
put it in a request header and read it there:
// proxy.ts
headers.set("x-pathname", request.nextUrl.pathname);
Validate it before you use it. A return path must start with a single /.
//evil.example and /\evil.example are both read by browsers as another
host, which turns your sign-in page into an open redirect. Check the path
after normalising it (new URL(value, base).pathname), not only the input:
/..//evil.example starts with one slash and folds to //evil.example.
Streaming changes the status code
If a loading.tsx sits above the layout that redirects, Next.js may already
have sent a 200 and the loading UI by the time the redirect happens. The
browser still ends up on sign-in (Next.js sends the redirect in the stream
and a meta refresh), but the status is 200, not 307. Crawlers, uptime checks
and curl see the difference. The proxy's redirect is what gives signed-out
visitors a real 307; this is the other reason to keep it.
Checklist
- The proxy redirects signed-out visitors for every protected prefix.
- The layout reads the session for the shell and redirects when there is none.
- Every page, server action and route handler checks again, with
cachemaking it free. - Return paths come from the page or a proxy header, and are validated.
- Hiding a nav link is cosmetic. The admin page behind it checks the role.