Valcraven Docs
Architecture

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 (in apps/web/lib/cache-policy.ts) sets the final Cache-Control — critically forcing no-store for 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.

apps/web/middleware.ts runs on the Edge runtime (OpenNext requires Edge for middleware). It does two things:

  1. Maintenance mode — when FLAG_MAINTENANCE_MODE=true, it rewrites all non-API, non-admin, non-static routes to /maintenance (admin routes stay reachable).
  2. Auth gating — for /app/* and /settings/*, it reads the session cookie with getSessionCookie() and redirects to /auth?tab=login when 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" and export 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 or users[0] id. See Conventions.
  • errorResponse(error) maps thrown AppError subclasses (UnauthorizedError, BadRequestError, …) from @/lib/errors to 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.

On this page