At a Glance
All non-prebuilt slugs
Routes crashing
ISR · revalidate 60s
Render mode preserved
7
Query call sites migrated
Anonymous · cookieless
New client
What Broke
The public war-stories detail page (/war-stories/[slug]) is rendered as a static page with background refresh: generateStaticParams builds the published slugs at deploy time, and export const revalidate = 60 re-renders them every minute.
That worked for the handful of slugs prebuilt at deploy. But the moment a request hit a slug that wasn't prebuilt — or a prebuilt page crossed its 60-second revalidate boundary — the render threw:
Error: Route /war-stories/[slug] used `cookies` ...
static to dynamic at runtime
The page didn't degrade or show an error boundary; it failed the render outright. For a portfolio whose entire value is the case studies, the pages meant to showcase the work were the ones falling over.
Root Cause
Next 16 draws a hard line between static and dynamic rendering. A route that declares generateStaticParams + revalidate is static (ISR). A route that touches a request-scoped API — cookies(), headers(), searchParams — is dynamic. You cannot do the second thing while Next has committed to the first; it throws static to dynamic at runtime rather than silently changing the rendering mode underneath you.
The crash had nothing to do with the war-stories code I'd written. It came from the data layer it called. Every query went through the shared createClient() in lib/supabase/server.ts, which is built on @supabase/ssr's createServerClient and reads request cookies to hydrate the auth session:
export async function createClient() {
const cookieStore = await cookies(); // <- dynamic API
return createServerClient(url, key, { cookies: { getAll() { ... } } });
}
That cookies() call is invisible from the page component — it's two function calls deep, inside a helper whose job sounds purely like "talk to the database." So an SSG page innocently called getWarStoryBySlug(slug), which reached for cookies, which flipped the render to dynamic, which Next refused.
The deeper lesson: a Supabase server client that always reads cookies is the wrong default for a site that has both public (sessionless) and admin (session-bound) surfaces. The auth session is meaningful only on the authenticated admin pages. Public pages have no user to authenticate — reading cookies there is not just unnecessary, it actively poisons static rendering. The cookie read was load-bearing for the wrong half of the app.
Timeline
Next 16 throws `static to dynamic at runtime: cookies`
/war-stories/[slug]rendered fine for slugs prebuilt at deploy, but crashed for any slug not ingenerateStaticParamsand on every 60-second revalidate boundary. The render failed outright rather than falling back to an error boundary.Traced the dynamic API to a cookie read two calls deep
The crash originated not in the page but in
lib/supabase/server.ts: the sharedcreateClient()callsawait cookies()to hydrate the auth session. The war-stories queries reached it throughgetWarStoryBySlug/getPublishedWarStorySlugs, dragging a dynamic API into a static render.Add `lib/supabase/public.ts`, swap it into the war-stories queries
Introduced a cookieless anonymous client (
persistSession: false,autoRefreshToken: false, URL normalized to origin) and pointed all seven war-stories query functions at it, leaving the cookie-aware client for the admin surface.Landed on `main` in commit d11af87
fix(war-stories): use cookieless client to keep detail SSG valid— the patch and ship were the same commit tomain.
The Fix
Public read-only pages don't need a session, so they shouldn't instantiate a client that reads one. The fix splits the client by audience: a cookieless anonymous client for public reads, and the existing cookie-aware client reserved for the authenticated admin surface.
Unchanged — shown for context. This shared client reads request cookies to hydrate the auth session. Correct for the authenticated admin surface, fatal for an SSG public page. It was reached transitively by the war-stories queries.
export async function createClient() {
const cookieStore = await cookies(); // dynamic API — flips a static page to dynamic
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
/* ... session refresh ... */
},
},
},
);
}The new cookieless client. No cookies() call, so it never flips a static render to dynamic. persistSession/autoRefreshToken are off because there's no server-side session for an anonymous reader, and the URL is normalized to its origin so a stray /rest/v1/ suffix can't break the SDK's path building.
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
// Anonymous, cookieless Supabase client for public read-only server queries.
// Why: pages with `generateStaticParams` + `revalidate` are treated as SSG by
// Next 16. A `cookies()` call during render flips them to dynamic at runtime
// and crashes with "static to dynamic" — see lib/supabase/server.ts. Use this
// client on unauthenticated public pages so ISR stays intact.
export function createPublicClient() {
// Strip any path (e.g., a stray `/rest/v1/`) so the SDK can append its own.
const rawUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const url = new URL(rawUrl).origin;
return createSupabaseClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
}All seven public war-stories query functions — getPublishedWarStories, getCardMetrics, getWarStoryFilterOptions, getWarStoryHeroMetrics, getPublishedWarStorySlugs, getWarStoryBySlug, getAdjacentWarStories — switched from the cookie-aware server client to the cookieless public one. Note the await drops: the public client is synchronous because it no longer awaits cookies().
import { createClient } from "@/lib/supabase/server";
// ... in each of 7 query functions:
const supabase = await createClient();import { createPublicClient } from "@/lib/supabase/public";
// ... in each of 7 query functions:
const supabase = createPublicClient();Two details earned their place in the new client. persistSession: false and autoRefreshToken: false make explicit that there is no session lifecycle to manage on the server for an anonymous reader. And new URL(rawUrl).origin strips any stray path (a misconfigured NEXT_PUBLIC_SUPABASE_URL carrying /rest/v1/) so the SDK can append its own REST path cleanly — a small piece of resilience that paid off again a week later when the same URL quirk resurfaced under a different error.
With the seven war-stories query functions pointed at createPublicClient(), no public render touches cookies(), so Next keeps the route static and ISR keeps working.
Verification
Verified against the exact failure conditions — a non-prebuilt slug and a revalidate boundary — not just the happy path that already worked at deploy.
A non-prebuilt slug renders without the crash
Requesting a published slug that wasn't in
generateStaticParamsnow renders on demand via ISR instead of throwingstatic to dynamic at runtime: cookies.The revalidate boundary no longer flips the page to dynamic
Crossing the 60-second
revalidatewindow re-renders the page statically in the background, as ISR intends, rather than failing.`generateStaticParams` still prebuilds published slugs
Deploy-time prebuilding of published war-story slugs works through the cookieless client, so the static path is unchanged for the slugs that were always fine.
Lessons Learned
A data-access helper that quietly reads cookies is a dynamic API wearing a static API's clothes.
- Dynamic APIs hide behind innocent-looking helpers.
getWarStoryBySlug()reads like pure data access, but two calls down it touchedcookies(). When a framework distinguishes static from dynamic, audit the whole call graph of a static page, not just the page component. - Split clients by audience, not by convenience. One Supabase client that always reads cookies is tempting because it's one import. But a site with both public and authenticated surfaces needs two: cookieless for public reads, cookie-aware for the session-bound admin. The session is meaningful in exactly one of those places.
- Defensive normalization compounds. Normalizing the Supabase URL to its origin was a throwaway line here, but it pre-paid for an unrelated
PGRST125failure a week later. Cheap robustness at a boundary is rarely wasted.