Request & auth lifecycle
How a request travels from the edge through the cache, middleware, the OpenNext worker, and into an API route — and where the session is checked at each step.
This page traces what happens between a browser hitting yourapp.example.com and your API route returning JSON. Understanding the order matters because the session is checked in two different places for two different reasons: cheaply in middleware (is there a cookie?) and authoritatively in the route (is the session valid?).
The path of a request
In production every request first reaches the Cloudflare Workers Cache in front of the Worker, then the app's custom worker.js entrypoint, then the OpenNext-generated handler, then Next.js middleware, and finally your route or page.
Browser
│
▼
Workers Cache ── HIT (anonymous, public) ──► cached response (Worker never runs)
│ MISS / BYPASS
▼
worker.js fetch(request, env, ctx)
│ → OpenNext handler → Next.js middleware (Edge) → route/page handler
│ ← response
▼
applyCachePolicy(request, response) # decides the final Cache-Control
▼
Browser- On a cache HIT the Worker does not run at all; the stored (anonymous-only) response is served.
- On a MISS/BYPASS the Worker runs, generates the response, and
applyCachePolicy(inapps/web/lib/cache-policy.ts) sets the finalCache-Control— critically forcingno-storefor any request that carries a session cookie, so authenticated renders are never cached. See Caching for the full policy.
worker.js is Valcraven's custom entrypoint that wraps OpenNext's generated worker and adds the cache policy plus the scheduled() and queue() handlers — covered in Cloudflare Workers & OpenNext.
Middleware: a cheap cookie gate
apps/web/middleware.ts runs on the Edge runtime (OpenNext requires Edge for middleware). It does two things:
- Maintenance mode — when
FLAG_MAINTENANCE_MODE=true, it rewrites all non-API, non-admin, non-static routes to/maintenance(admin routes stay reachable). - Auth gating — for
/app/*and/settings/*, it reads the session cookie withgetSessionCookie()and redirects to/auth?tab=loginwhen the cookie is absent.
import { getSessionCookie } from "better-auth/cookies";
if (pathname.startsWith("/app") || pathname.startsWith("/settings")) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL("/auth?tab=login", request.url));
}
}Middleware only checks for the presence of a cookie — it does not validate the session.
getSessionCookie()is a fast, optimistic check that keeps signed-out users off protected pages without a database round-trip. The real validation happens in the route (below). This is why you never rely on middleware alone for authorization, and why you don't need to add your own auth check to pages under/app— the gate is already there.
API routes: the authoritative session check
Every API route resolves and validates the session with auth.api.getSession, scopes all data to session.user.id, and funnels errors through errorResponse(). This is the canonical shape (from apps/web/CLAUDE.md):
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getDb } from "@/lib/db";
import { UnauthorizedError, errorResponse } from "@/lib/errors";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new UnauthorizedError();
const userId = session.user.id;
// ... scope every query to userId ...
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}A few rules this template enforces, each for a concrete reason:
export const dynamic = "force-dynamic"andexport const runtime = "nodejs"on every route that uses the database or a service. Routes need the Node.js runtime (not Edge) and must not be statically optimized.auth.api.getSession({ headers: request.headers })is the only correct way to read the session server-side. It re-derives and validates the session on each call.- Scope every query to
session.user.id. The schema is multi-tenant by default — never use a hardcoded orusers[0]id. See Conventions. errorResponse(error)maps thrownAppErrorsubclasses (UnauthorizedError,BadRequestError, …) from@/lib/errorsto the right status and a safe message — no stack traces leak to the client.
Lazy auth init (why auth is a Proxy)
auth (from @/lib/auth) is exported as a Proxy that defers building the Better Auth instance until first access. On Cloudflare Workers the D1 binding is request-scoped and doesn't exist at module load, so eagerly constructing betterAuth() at import time would crash the Worker before any request runs. The OpenNext adapter provides the binding per request; the first auth.api.* call then builds against it. On SQLite/Postgres this is functionally identical to a top-level instance — it just moves initialization from import time to first use. See the data layer for how the D1 binding is resolved.
Client-side session
In React components, use the client (authClient from @/lib/auth-client), never the server auth:
import { authClient } from "@/lib/auth-client";
const { data: session } = await authClient.getSession();Never import @/lib/auth in client code — it's server-only. See Authentication for the full client API.
Monorepo layout
The npm-workspace layout — apps/web, apps/realtime, apps/mobile, and packages/core — what each workspace is, how they relate, and the rules that keep the shared package portable.
Cloudflare Workers & OpenNext
How a Next.js app runs on Cloudflare Workers via OpenNext — the custom worker.js entrypoint, wrangler bindings, scheduled and queue handlers, and the nodejs_compat story.