React Protected Routes: What a Server-Rendered Guard Does Differently
React Router's client-side wrapper isn't the only pattern. This codebase's redirect guard runs on the server, plus the open-redirect check most tutorials skip on the ?next= param.
A protected route redirects an unauthenticated visitor before they see private content. In React Router apps that means a client-side <ProtectedRoute> wrapper checking auth state after mount. In a Next.js App Router codebase — this one included — there is no router-level wrapper at all: the guard lives in a Server Component layout that runs before any HTML reaches the browser, and the middleware layer most tutorials assume does the redirect turns out not to be involved.
What actually protects /dashboard here
This storefront's proxy (src/proxy.ts, the renamed middleware in Next 16) runs on every request and refreshes the Supabase session — but it does not redirect anyone:
// src/proxy.ts
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(/* … */);
await supabase.auth.getClaims();
return response;
}
No if (!session) redirect(...) anywhere in it. The actual gate is a Server Component layout, shared by every route under /dashboard:
// src/app/dashboard/layout.tsx
export default async function DashboardLayout({
children,
}: {
children: ReactNode;
}) {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
redirect("/login?next=/dashboard");
}
return (
<>
<Header />
<main id="main">{children}</main>
<Footer />
</>
);
}
That single redirect() call protects /dashboard, /dashboard/settings, and every future route nested under the same layout, with zero per-page code. getClaims() — never getSession() — because getSession() reads a cookie without verifying its signature; getClaims() validates the JWT against Supabase's public key before trusting anything in it.
Why this isn't the pattern the top search results show
Search "react protected routes" and the top results — a Medium walkthrough, GeeksforGeeks, an Appwrite tutorial — all describe the same shape: a <ProtectedRoutes> component wrapping <Outlet>, checking an auth context after the component mounts, and calling <Navigate to="/login" /> if it fails. That pattern exists because React Router has no server: routing, rendering and the auth check all happen in the browser, after JavaScript has already downloaded and executed. There is an unavoidable window — however brief — where the client doesn't yet know if the visitor is authenticated.
An App Router layout has no such window. DashboardLayout runs on the server, resolves the session, and only ever sends the browser one of two responses: a redirect, or the actual protected HTML. Nothing protected is ever shipped to a client that shouldn't see it, and there's no useEffect, no loading spinner, no auth-context provider to wire up.
React Router <ProtectedRoute> | Next.js shared layout guard | |
|---|---|---|
| Where the check runs | Client, after mount | Server, before any bytes leave |
| Auth state source | Context/store, populated async | getClaims() on the request |
| Unauthenticated flash | Possible — content can mount before redirect fires | Never — redirect happens before render |
| Wiring per new route | Wrap the route in the guard component | Nothing — inherit the parent layout |
| Where redirect happens | <Navigate> in a Client Component | redirect() in a Server Component |
| Depends on | React Router's <Outlet> nesting | Next's file-system layout nesting |
Route-level protection isn't the whole story, though — it only stops someone from navigating to a page. Any Server Action or API route reachable independently of the page still needs its own check, a point the Server Actions comparison makes explicitly: a Server Action is a public POST endpoint, not an internal function call, so ownership has to come from the verified session inside the action, never from a client-supplied id.
The redirect target is itself untrusted input
?next= is how the login page knows where to send someone back after signing in — and it is also the textbook shape of an open-redirect vulnerability if handled naively. router.push(nextParam) on an unvalidated string lets an attacker craft yoursite.com/login?next=https://evil.example.com (or the sneakier //evil.example.com, which browsers resolve as protocol-relative) and use your own login flow to bounce a victim off-site right after they authenticate.
This codebase validates it with one regex:
// src/lib/validation.ts
const SAFE_NEXT_RE = /^\/(?![/\\])/;
export function safeNext(next: string | null | undefined): string {
return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}
^\/ requires the value to start with a single /; the negative lookahead (?![/\\]) rejects a second / or a \ immediately after it. That second character is the part a same-origin-looking check usually misses — //evil.example.com passes a naive startsWith("/") test, and browsers treat a leading // as "same scheme, different host," which is exactly the redirect. A stray backslash gets excluded for the same reason some legacy URL parsers silently normalize \ to /. Anything that fails the pattern falls back to /dashboard rather than erroring — a safe default beats a broken login page.
safeNext() runs on the login page before the value ever reaches a redirect:
// src/app/login/page.tsx
const { next: nextParam } = await searchParams;
const next = safeNext(nextParam);
// ...
<AuthCard action={signInWithPassword} redirectTo={next} googleNext={next} />
Building the same guard yourself
// app/(protected)/layout.tsx — the whole pattern in one file
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
redirect("/login?next=/dashboard");
}
return <>{children}</>;
}
Every route nested under this layout inherits the guard automatically — a new app/(protected)/billing/page.tsx is protected the moment it's created, with no import to remember. This is also why the layout is ƒ (dynamic) rather than statically rendered: getClaims() reads the request's cookies, and reading per-request data in a shared layout forces the whole subtree dynamic — correct here, since a static page can't run a per-visitor check at all.
Authenticated isn't the same check as authorized
Everything above answers "is anyone signed in" — it says nothing about whether this signed-in visitor should see this data. DashboardLayout's guard is deliberately coarse: it protects the route shell, not any particular row of data rendered inside it. The finer-grained check happens per query, further down: a dashboard page fetching a user's own orders scopes that query to the authenticated user's id, taken from the verified session — never from a route param or a client-supplied value, which a visitor could edit in the URL bar regardless of whether they're logged in as themselves. Row-level authorization is a separate, per-resource concern from route-level authentication, and conflating the two is how a correctly-redirecting login flow still ships a data leak: the route redirected the wrong visitor away, but never checked whether the right visitor should see every row it renders. Role-based access control for an admin dashboard covers the next layer up from here — gating entire sections of a protected app by role rather than just by "signed in or not."
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
Checking auth in a Client Component with useEffect | Protected content flashes before the redirect fires | Do the check in a Server Component layout, before anything renders |
Using getSession() for the guard | Trusts an unverified cookie; a tampered cookie can pass the check | Use getClaims(), which validates the JWT signature |
Trusting ?next= verbatim in a redirect | Open redirect — a crafted link bounces users to an attacker's domain | Validate with an allowlist regex; fall back to a known-safe default |
| Protecting the page but not the Server Action it calls | The route redirects unauthenticated visitors, but the action underneath is still callable directly with the right request shape | Re-check the session inside every Server Action and API route, not just the page |
| Assuming middleware redirects for you | No visible error — pages just render for everyone, because the middleware never checked | Confirm the layer that reads claims is also the layer that calls redirect() |
| One route protected, a sibling route forgotten | Inconsistent protection as new pages get added | Nest new private routes under the shared guarded layout instead of copy-pasting a check |
Frequently asked questions
Does middleware protect my routes automatically? No — middleware runs on every matched request, but unless it explicitly reads the session and calls a redirect, it protects nothing. This codebase's proxy only refreshes the session; the actual gate is a layout further down the tree. Check what your middleware does, not just that one exists.
Is a layout guard enough, or do I still need per-page checks? A layout guard stops navigation to a page. It does not protect a Server Action or API route that's reachable independently of that page — those need their own session check, because a client can call them directly without ever rendering the guarded layout.
Why getClaims() instead of getSession()?
getSession() reads whatever is in the cookie without verifying it — a client that tampered with a stored session object would pass. getClaims() validates the JWT's signature against Supabase's key before your code trusts any field in it.
Do I need a client-side auth context at all with this pattern? Not for gating pages. A Client Component still needs to know the current user for interactive UI (a profile menu, conditional buttons), but that's a display concern, not a security boundary — the boundary is the server-side check that runs before the page renders.
Templates where this pattern is load-bearing
Every one of ASoc's admin products ships the same shape: a full auth flow gating a dashboard shell. ASoc Apex pairs it with a 115-page multi-dashboard build and a full ecommerce back office; ASoc Clover is CRM-focused with its own auth screens and a UI kit; ASoc Pulse runs five dashboards plus a store back office behind the same kind of guard, dark mode included.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For what happens once inside a protected route and the request shape doesn't come through the client you expect, see Server Actions vs. API routes.
