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 guardfunctionProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <Spinner />; // don't flash either way yetif (!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).
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 stacksfunctionRoutes() {
const { user, loading } = useAuth();
if (loading) return <Splash />; // while reading secure storagereturn 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.
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.
Technique
Where
Use it for
ProtectedRoute wrapper
React SPA
Per-route auth/role check that redirects. The default SPA pattern.
Conditional navigator
React Native
AppStack vs AuthStack by auth state. Protected screens don't exist when logged out.
middleware.ts
Next.js
Edge redirect before render for whole sections (/dashboard/*).
Server Component check + redirect()
Next.js
Fine-grained, data-aware gating (role from DB). No protected HTML leaks.
Route loader / guard hook
Any router
Check auth in the data loader before the component mounts.
API auth middleware
Server
The 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.