Protecting web pages — middleware + ProtectedWebRoute

Two patterns, one command. SSR cookie gate for whole sections, client wrapper for role gates.

10 minmedium

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

Terminal
$grit add web-auth

You'll see:

Adding web-auth helpers to apps/web/
✓ wrote apps/web/middleware.ts
✓ wrote apps/web/components/ProtectedWebRoute.tsx
Next 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:

PatternLives inWhen to use
Middleware (SSR)middleware.tsWhole-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:

apps/web/middleware.ts (excerpt)
// 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.
Why not verify the JWT? Verifying the cookie would require a network call to the API on every page request — adding 50-200ms of latency to your homepage too. The middleware checks cookie existence, which is instant. Invalid or expired cookies still bounce — but at the next API call (which fails with 401, which is caught by 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/me probe.
  • Per-page protection — you don't want to add yet another path to the middleware matcher.
apps/web/app/billing/page.tsx
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:

<ProtectedWebRoute
fallback={
<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

You added `/checkout` to PROTECTED_PATHS in middleware.ts but forgot to add it to the config.matcher at the bottom. What happens when a logged-out visitor hits /checkout?

Try it

Add web-auth to your contact-app and protect a new page end-to-end:

  1. Run grit add web-auth.
  2. Create a new page at apps/web/app/account/page.tsx that renders a simple heading.
  3. Add /account to both PROTECTED_PATHS and config.matcher in middleware.ts.
  4. In an incognito tab, visit http://localhost:3000/account — you should bounce to /login?next=%2Faccount.
  5. Sign in. After redirect, you should land back on /account (if your login page reads the next param) or on the default post-login page.
  6. 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