Protecting web pages — middleware + ProtectedWebRoute
Two patterns, one command. SSR cookie gate for whole sections, client wrapper for role gates.
The admin panel auto-protects its dashboard routes — visit one without a session and you bounce to /login automatically. The customer-facing web app at apps/web/ doesn't do this by default; it's designed to host public marketing pages alongside member-only areas, so blanket protection would break the marketing side. grit add web-auth (shipped in v3.31.22) gives you a one-shot command that drops two opt-in tools into the web app: a cookie-checking middleware and a client-side wrapper.
Running the command
$grit add web-auth
You'll see:
Adding web-auth helpers to apps/web/✓ wrote apps/web/middleware.ts✓ wrote apps/web/components/ProtectedWebRoute.tsxNext steps:1. Open apps/web/middleware.ts and add protected paths to the matcher.2. Or wrap a page client-side with <ProtectedWebRoute>.3. See /docs/concepts/protecting-web-pages for both patterns.
Re-run it any time — it's idempotent. Existing files are skipped with a notice unless you pass --force.
Two patterns, one command
Web-auth is intentionally not a single mechanism. Different pages need different protection guarantees, and forcing one approach onto everything leads to bad trade-offs:
| Pattern | Lives in | When to use |
|---|---|---|
| Middleware (SSR) | middleware.ts | Whole-section gates: /account, /checkout. Fast, no flash. |
| Wrapper (client) | <ProtectedWebRoute> | Role-gated content. Per-page protection without editing middleware. |
Pattern 1 — Middleware (SSR cookie gate)
Open apps/web/middleware.ts and you'll find two arrays at the top:
// Add paths here that require an authenticated visitor.const PROTECTED_PATHS: string[] = ["/account","/account/:path*",// "/checkout",// "/dashboard",];// Paths that should be inaccessible to ALREADY-signed-in users// (the login form, sign-up). Sends them to /account instead.const AUTH_PATHS: string[] = ["/login","/register","/forgot-password",];
The middleware runs on every Next.js request matching its config matcher. It checks for the grit_access HttpOnly cookie:
- No cookie + protected path → redirect to
/login?next=<original-path>. After login succeeds, the auth flow can read?next=and send the visitor back to where they were headed. - Has cookie + auth path → bounce to
/account. No reason to show the login form to someone who's already signed in. - Anything else → pass through unchanged.
useMe(), which redirects). The middleware prevents the unauthenticated-visitor-sees-account-shell flash without the per-request cost.Editing the matcher
The config.matcher at the bottom of middleware.ts tells Next.js which paths to run the middleware on. Keep it in sync with PROTECTED_PATHS + AUTH_PATHS — anything outside the matcher bypasses the middleware entirely (good for performance on static assets, blog pages, marketing routes):
export const config = {matcher: ["/account/:path*","/checkout", // ← add new paths here when you add them to PROTECTED_PATHS"/login","/register","/forgot-password",],};
Pattern 2 — ProtectedWebRoute (client wrapper)
The middleware is fast but can only check cookie existence. Three cases it can't handle:
- Expired cookies — the cookie is present (middleware passes through) but invalid. The page shell renders before the first API call exposes the problem.
- Role gates — "this page is only for ADMIN users." The cookie doesn't carry the role; you need a real
/api/auth/meprobe. - Per-page protection — you don't want to add yet another path to the middleware matcher.
import { ProtectedWebRoute } from "@/components/ProtectedWebRoute";export default function BillingPage() {return (<ProtectedWebRoute><h1>Billing</h1><BillingDetails /></ProtectedWebRoute>);}
The wrapper calls useMe(), shows a spinner while the probe is in flight, redirects to /login?next=… on null user, and renders children when authenticated.
Role gating
Pass a roles prop to restrict to specific roles. Visitors with a session but the wrong role are redirected to / (the landing page) — not /login, since they're already signed in.
<ProtectedWebRoute roles={["ADMIN", "EDITOR"]}><CMSPage /></ProtectedWebRoute>{/* Single role works too — pass a string instead of an array */}<ProtectedWebRoute roles="ADMIN"><SuperUserOnly /></ProtectedWebRoute>
Custom loading view
The default spinner is intentionally bland. Pass a fallback prop to replace it with a skeleton that matches the page's real layout:
<ProtectedWebRoutefallback={<div className="space-y-3 p-8"><div className="h-8 w-48 rounded bg-slate-200 animate-pulse" /><div className="h-32 rounded bg-slate-100 animate-pulse" /></div>}><AccountPage /></ProtectedWebRoute>
Combining both patterns
Middleware and wrapper aren't alternatives — they layer:
HTTP request → /account/billing│▼middleware.ts checks grit_access cookie│ missing? → redirect /login?next=/account/billing ✗│ present? → pass through ✓│▼page hydrates: <ProtectedWebRoute roles="ADMIN">│ useMe() probes /api/auth/me│ 200 + correct role → render <BillingPage /> ✓│ 200 + wrong role → redirect / ✗│ 401 → redirect /login ✗│
The middleware catches the "no cookie at all" case (which is 99% of unauthorized visitors), shaving network calls and flash. The wrapper catches the rare "cookie present but invalid/expired/wrong-role" cases.
Quick check
Try it
Add web-auth to your contact-app and protect a new page end-to-end:
- Run
grit add web-auth. - Create a new page at
apps/web/app/account/page.tsxthat renders a simple heading. - Add
/accountto bothPROTECTED_PATHSandconfig.matcherin middleware.ts. - In an incognito tab, visit
http://localhost:3000/account— you should bounce to/login?next=%2Faccount. - Sign in. After redirect, you should land back on
/account(if your login page reads thenextparam) or on the default post-login page. - Wrap the page contents in
<ProtectedWebRoute roles="ADMIN">. Visit again. If your seed user is ADMIN, you should see the page. If you make your role something else (e.g. via the admin user list), you should bounce to/.
Chapter recap
This is the last lesson in the resource-lifecycle chapter. You can now:
- Generate full-stack resources (
grit generate) - Sync types and propagate Go changes to TypeScript (
grit sync) - Remove resources cleanly (
grit remove) - Customise the admin form (render mode, groups + PATCH, field types)
- Customise the admin table (formats, packed columns, filters)
- Expose forms and tables to the web app (
grit expose) - Share resources publicly with a token (FormShare + /forms/[token])
- Protect customer-facing pages (
grit add web-auth)
A complete model lifecycle from Go struct to authenticated customer-facing page, all from a single resource definition. That's the whole pitch.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub