Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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 runsClient, after mountServer, before any bytes leave
Auth state sourceContext/store, populated asyncgetClaims() on the request
Unauthenticated flashPossible — content can mount before redirect firesNever — redirect happens before render
Wiring per new routeWrap the route in the guard componentNothing — inherit the parent layout
Where redirect happens<Navigate> in a Client Componentredirect() in a Server Component
Depends onReact Router's <Outlet> nestingNext'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

MistakeHow it shows upFix
Checking auth in a Client Component with useEffectProtected content flashes before the redirect firesDo the check in a Server Component layout, before anything renders
Using getSession() for the guardTrusts an unverified cookie; a tampered cookie can pass the checkUse getClaims(), which validates the JWT signature
Trusting ?next= verbatim in a redirectOpen redirect — a crafted link bounces users to an attacker's domainValidate with an allowlist regex; fall back to a known-safe default
Protecting the page but not the Server Action it callsThe route redirects unauthenticated visitors, but the action underneath is still callable directly with the right request shapeRe-check the session inside every Server Action and API route, not just the page
Assuming middleware redirects for youNo visible error — pages just render for everyone, because the middleware never checkedConfirm the layer that reads claims is also the layer that calls redirect()
One route protected, a sibling route forgottenInconsistent protection as new pages get addedNest 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.

Keep reading

Tutorial10 min read

A React Sidebar in 60 Lines and Zero JavaScript

Sticky positioning without a scroll listener, aria-current for the active row, and the hard-coded active-state bug that only appears when you add a second page.

Read more