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 proxy 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/proxy.tsโ wrote apps/web/components/ProtectedWebRoute.tsxNext steps:1. Open apps/web/proxy.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 |
|---|---|---|
| Proxy (SSR) | proxy.ts | Whole-section gates: /account, /checkout. Fast, no flash. |
| Wrapper (client) | <ProtectedWebRoute> | Role-gated content. Per-page protection without editing the proxy. |
Pattern 1 โ Proxy (SSR cookie gate)
Open apps/web/proxy.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 proxy 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 proxy prevents the unauthenticated-visitor-sees-account-shell flash without the per-request cost.Editing the matcher
The config.matcher at the bottom of proxy.ts tells Next.js which paths to run the proxy on. Keep it in sync with PROTECTED_PATHS + AUTH_PATHS โ anything outside the matcher bypasses the proxy 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 proxy is fast but can only check cookie existence. Three cases it can't handle:
- Expired cookies โ the cookie is present (the proxy 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 proxy 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โโผproxy.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 proxy 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 proxy.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) โ including the v3.31.16 auto-add into admin resource files - Remove resources cleanly (
grit remove) - Reach for the right field type โ including
:file:/:files:with FileRef lifecycle (v3.31.30-33) and the v3.31.35 client-side Excel import/export - Customise the admin form (formView, groups + per-group PATCH, field types)
- Customise the admin table (formats, packed columns, filters, dateFilter, ExportMenu, ImportModal)
- Expose forms and tables to the web app (
grit expose) - Share resources publicly with a token (FormShare + /forms/[token] + the v3.31.25 audit trail)
- 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