ROUTE_GUARD // Public vs Private Pages & Screens

An open lobby, a keycard for the upstairs floors, and a guard who actually checks.

How to protect routes in React, React Native, and Next.js — public vs private pages, every way to guard them, and the one rule that keeps it honest: the client only hides, the server decides.

The one story to remember

Think of your app as an office building. The lobby is open to anyone — that's a public page (login, marketing, pricing). The upstairs floors need a keycard — those are private pages (dashboard, settings, billing). A guard at the elevator checks your card before letting you up (a route guard). But here's the part everyone forgets: a determined person can take the stairs. So the real lock is on the door of each office (the server / API). The elevator guard is convenience and UX; the office-door lock is the actual security. Hide on the client, enforce on the server — always both.

1User requests a route e.g. /dashboard
2Guard checks: is there a valid session/token? and the right role, if needed
3aAuthorized → render the page show the protected content
3bNot authorized → redirect → /login (no session) or /403 (wrong role)
4The page's data call hits the API which independently re-checks auth — the real gate
01

Public vs private pages

Technical definition A public route is accessible without a session; a private (protected) route requires a valid session and redirects unauthenticated users away.

Every screen falls into one of two buckets. Getting this split right before you write guards saves a lot of pain.

Public

Anyone can see it, logged in or not. Login, signup, landing page, pricing, public blog posts, password reset.

Private (protected)

Needs a valid session. Dashboard, profile, settings, checkout, anything user-specific.

some routes are "auth-only-public": /login should bounce you AWAY if you're already logged in
Building The lobby (public) is for everyone. The keycard floors (private) are for tenants. And the lobby's front desk gently turns a tenant around — "you're already checked in, go on up" — instead of making them sign in twice.
Sort every route into public, private, or public-but-redirect-if-authed (login/signup). That list is the spec your guards implement.
02

The golden rule: client hides, server enforces

Technical definition Client-side guards are UX only and can be bypassed; the server is the real security boundary and must re-verify auth on every protected request.

This is the single most important idea, and the one juniors miss. Any check that runs in the browser or the app can be bypassed. A user can edit JS in dev tools, call your API directly with curl, or root their phone. So front-end guards are UX, not security.

Building The elevator guard makes the building pleasant — you don't wander onto floors you can't enter. But if someone takes the stairs, only the locked office door stops them. The door lock is the API. Never ship a building with a guard and unlocked offices.
The classic failure "I hid the Admin button, so non-admins can't delete users." They can — they just call DELETE /api/users/42 directly. Hiding the button changed nothing on the server. Every protected action must be re-checked by the API.
Front-end route guards = convenience & UX. The server is the only real security boundary. Do both: hide on the client, enforce on the server.
03

React (SPA) — guard with a wrapper component

Technical definition A ProtectedRoute is a wrapper component that checks auth state and either renders the page or redirects (e.g. to /login).

In a plain React SPA, routing happens in the browser. You protect a route by wrapping it in a component that checks auth and either renders the page or redirects.

// ProtectedRoute.jsx — the elevator guard
function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();

  if (loading) return <Spinner />;          // don't flash either way yet
  if (!user) return <Navigate to="/login" replace />;
  return children;
}

// usage in the router
<Route path="/dashboard" element={
  <ProtectedRoute><Dashboard /></ProtectedRoute>
} />

For role-based routes, pass an allowed-roles list and redirect to a "403 / not allowed" page when the role doesn't match — that's authorization on top of authentication.

Building Authentication = "do you have a keycard at all?" Authorization = "does your keycard open this floor?" A regular tenant's card works the lobby and their floor, not the executive suite.
SPA pattern: a ProtectedRoute wrapper that handles loading → redirect-if-no-user → render. Handle the loading state first, or you'll flash the login page before auth resolves.
04

Next.js — guard on the server, before the page renders

Technical definition In Next.js you gate routes before render — in middleware.ts at the edge, or inside a server component with redirect() — so no protected HTML is sent to unauthorized users.

Next.js can check auth before a single byte of the page reaches the browser — a real advantage over a pure SPA. There are two main spots:

middleware.ts

Runs at the edge before the route resolves. Read the session cookie; redirect to /login if missing. Best for broad "this whole section is private" rules.

In the Server Component / layout

Check the session in the server component and redirect(). Best for fine-grained, data-aware checks (e.g. role from the DB).

// middleware.ts — gate a whole section before render
export function middleware(req) {
  const token = req.cookies.get("session")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
Next.js lets you redirect before render in middleware.ts (broad rules) or inside a server component (fine-grained). No protected HTML is ever sent to an unauthorized user — and your route handlers / server actions still re-check.
05

React Native — guard by swapping navigators

Technical definition In React Native you render a different navigator based on auth state (AppStack vs AuthStack), so protected screens don't exist in the tree when logged out.

Mobile doesn't have URLs to intercept, so the pattern is different and actually cleaner: you render a different navigator depending on auth state. No "protected screen" can exist in the tree when the user is logged out.

// App navigation — two separate stacks
function Routes() {
  const { user, loading } = useAuth();

  if (loading) return <Splash />;            // while reading secure storage
  return user
    ? <AppStack />      // dashboard, profile, settings
    : <AuthStack />;    // login, signup, forgot-password
}
Building Instead of guarding each floor, the building literally shows logged-out visitors a different building with only a lobby. The private floors aren't behind a guard — they don't exist on their map until they check in.
RN pattern: read the token from secure storage on launch (show a splash while you do), then render AppStack or AuthStack. Conditional navigators beat per-screen guards on mobile.
★ THE FULL PICTURE

Every layer of protection at once

Real protection is layered. The client guard is the outer convenience; the server is the inner truth. Read the arrows top-to-bottom: the request passes (or fails) the client guard, then independently passes (or fails) the server check.

USER opens /dashboard "someone walks toward the elevator" ① CLIENT GUARD (UX layer) ProtectedRoute · middleware · navigator swap has session/token? right role? yes → show page no → redirect ② SERVER / API (the REAL lock) "the locked office door — stairs won't help" verify token / session 🛡 check role / ownership (authorization) 🔐 allowed → 200 data denied → 401/403 runs even if the client guard was bypassed ↑ ✦ THE BYPASS ("taking the stairs") curl, dev tools, rooted device — skips the client guard entirely → still hits the SERVER check → → which is why the server must decide data call attacker route bypass still meets the server lock Two independent gates. The second one is the one that matters. ↓
Public routevisible to everyone; no session required (login, landing, pricing).
Private routerequires a valid session; redirect to login if missing.
Authentication"who are you?" — is there a valid session/token at all.
Authorization"are you allowed here?" — role/ownership check on top of auth.
Client guardwrapper / middleware / navigator swap — UX, hides what you can't access.
Server checkthe real boundary — re-verifies every protected request and action.

Read it as a sentence: sort routes into public and private → guard private ones on the client for UX → redirect the unauthenticated to login and the unauthorized to 403 → and re-check auth on the server for every request, because the client guard can always be bypassed. That single sentence is the entire model.

If you can draw two gates — a client guard for UX and a server check for truth — and explain why the second is the one that matters, you can answer almost any "how do you protect a route" question on the spot.
06

Every way to guard, side by side

The same job — "don't let the wrong person see this" — has a platform-shaped answer. Here's the menu.

TechniqueWhereUse it for
ProtectedRoute wrapperReact SPAPer-route auth/role check that redirects. The default SPA pattern.
Conditional navigatorReact NativeAppStack vs AuthStack by auth state. Protected screens don't exist when logged out.
middleware.tsNext.jsEdge redirect before render for whole sections (/dashboard/*).
Server Component check + redirect()Next.jsFine-grained, data-aware gating (role from DB). No protected HTML leaks.
Route loader / guard hookAny routerCheck auth in the data loader before the component mounts.
API auth middlewareServerThe mandatory one. Verify token + role on every protected endpoint. Everything above is optional UX; this is not.
Pick the client technique that fits your platform — but the API auth middleware row is never optional. It's the only one that actually stops an attacker.

Rapid-fire one-liners

What's the difference between a public and a private route?
Public routes are visible to everyone (login, landing). Private routes require a valid session and redirect to login if there isn't one.
Is hiding a page on the front end enough to secure it?
No. Anyone can bypass the client via dev tools or direct API calls. Front-end guards are UX; the server must re-check every protected request.
Authentication vs authorization?
Authentication is "who are you?" (valid session). Authorization is "are you allowed to do this?" (role/ownership). You need both.
How do you protect a route in a React SPA?
A ProtectedRoute wrapper that handles loading, redirects to /login when there's no user, and renders the page otherwise — with an optional allowed-roles check.
How is it different in Next.js?
You can check auth on the server before render — in middleware.ts for broad rules, or in a server component with redirect() for fine-grained ones — so no protected HTML is ever sent.
How do you protect screens in React Native?
Render a different navigator based on auth state — AppStack when logged in, AuthStack when not — after reading the token from secure storage on launch.
Where should a logged-in user be sent if they open /login?
Redirect them away to the dashboard. Login/signup are "public but redirect-if-authenticated" routes.
401 vs 403?
401 = not authenticated (no/invalid session) → send to login. 403 = authenticated but not authorized (wrong role) → send to a "not allowed" page.