Authentication in Next.js: Middleware, FOUC, and Feature Gating
Authentication seems simple: check if the user is logged in, show them the right page. In practice, Next.js auth has three problems that trip up every team.
Problem 1: Route Protection
The naive approach — checking auth inside each page component — is wrong:
// ❌ Page renders on server, then redirects — the HTML was already sent
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect("/login")
return <Dashboard />
}
The page component runs on the server, generates HTML, sends it to the client, then redirects. For a split second, the dashboard HTML is in the response. Bots and fast connections can see it.
The correct approach: middleware.
// middleware.ts — runs BEFORE the page renders
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
const isProtected = createRouteMatcher(["/dashboard(.*)", "/settings(.*)"])
export default clerkMiddleware(async (auth, req) => {
if (isProtected(req)) await auth.protect()
})
Middleware runs at the edge, before any page code executes. Unauthenticated requests never reach the page. No leaked HTML, no race conditions.
Problem 2: The Auth FOUC
Flash of Unauthenticated Content happens when:
- Server renders the navbar with no auth info (shows "Sign In" button)
- Auth provider hydrates on the client and detects a session
- Navbar re-renders with "Dashboard" link
- User sees the swap — a layout shift
This happens because the server doesn't know about the client-side auth session when rendering shared layouts like the navbar and footer.
The fix: pass auth state from the server to client components as an initial prop.
// Server layout — knows auth state at render time
export default async function Layout({ children }) {
const { userId } = await auth()
return (
<>
<Navbar initialSignedIn={!!userId} />
{children}
</>
)
}
// Client navbar — uses server-provided initial state
"use client"
export function Navbar({ initialSignedIn }: { initialSignedIn: boolean }) {
const { isSignedIn } = useAuth()
const signedIn = isSignedIn ?? initialSignedIn
return signedIn ? <DashboardLink /> : <SignInButton />
}
First render uses the server-provided value. Once the auth provider hydrates, the live value takes over. No flash.
Problem 3: Feature Gating by Plan
"Show this button only to Pro users" sounds simple. It's not, because the check needs to work in three contexts:
Server Components
export default async function Page() {
const isPro = await checkProStatus()
return isPro ? <ProFeature /> : <UpgradePrompt />
}
The server reads plan status from your auth provider's metadata or your database. No client JS, no loading state.
Client Components
"use client"
export function FeatureBadge() {
const { user } = useAuth()
const isPro = user?.publicMetadata?.plan === "pro"
return isPro ? <Badge>Pro</Badge> : null
}
The client reads from the auth provider's cached user object. No additional API call.
Middleware
// Block non-Pro users from premium API routes
if (req.nextUrl.pathname.startsWith("/api/pro/")) {
const plan = sessionClaims?.metadata?.plan
if (plan !== "pro") {
return Response.json({ error: "Pro required" }, { status: 403 })
}
}
All three contexts need to agree on the source of truth. The pattern that works: store the plan in your auth provider's public metadata. The payment webhook updates it. Every context reads from the same place.
The Payment Sync Problem
When a user pays, your payment provider (Stripe, Lemon Squeezy, etc.) fires a webhook. Your handler needs to:
- Verify the webhook signature
- Update your database (permanent record)
- Update your auth provider's metadata (what the UI reads)
- Send a confirmation email
The critical ordering: database first, then auth metadata. If the auth update fails, the database record serves as a fallback for server-side checks.
Idempotency
Webhooks can fire multiple times. Your handler must be idempotent:
// Check before inserting — don't create duplicate records
const existing = await db.query.licenses.findFirst({
where: eq(licenses.paymentId, event.id),
})
if (existing) return Response.json({ received: true })
The Success Page Trap
After payment, users land on a success page. The webhook may not have processed yet. Don't show "You're now Pro!" because the metadata might not be updated.
Instead: "Your access is being activated. This usually takes a few seconds." Then let the dashboard detect the updated status on next load.
Summary
| Problem | Wrong approach | Right approach |
|---|---|---|
| Route protection | Check auth in page component | Middleware — runs before render |
| Auth FOUC | Let client hydration fix it | Pass `initialSignedIn` from server |
| Feature gating | Different checks in different places | Single source (auth metadata), read everywhere |
| Payment sync | Trust the redirect | Trust the webhook, make it idempotent |
Auth is infrastructure. The goal is to set it up once and never debug it again. These patterns get you there.