Fix: Route couldn't be rendered statically (cookies)
Dynamic server usage: Route /x couldn't be rendered statically because it used cookies is not a Supabase error — it's Next.js telling you that its automatic opt-out of static rendering failed. During static generation, cookies() throws a DynamicServerError that Next.js catches to mark the route dynamic. If your code catches it first, or calls cookies() outside the render's async context, the bailout never lands and the build fails. Fix the call site; reach for export const dynamic = "force-dynamic" only when you genuinely want the route always dynamic.
It shows up as a build failure, which puts it in the worst category of bug: deploys are blocked and the error names a Next.js internal rather than the line that caused it. The Supabase repo has issue #22718 for it, and the Next.js repo has issue #54743 — the volume comes from the App Router + Supabase auth combination, because reading the session means reading a cookie on every protected route.
What Next.js is actually doing
The mechanism is worth understanding, because it explains every variant of this error at once. From the Next.js error reference:
"While generating static pages, Next.js will throw a
DynamicServerErrorif it detects usage of a dynamic function, and catch it to automatically opt the page into dynamic rendering. However, when it's uncaught, it will result in this build-time error."
So the throw is the signal, not the failure. Next.js prerenders your route, cookies() throws, Next.js catches its own exception and re-renders the route dynamically. You never see anything. The build only breaks when that exception is intercepted before Next.js can see it, or when it's thrown somewhere Next.js isn't watching.
Why does the build say my route couldn't be rendered statically?
Because the bailout exception didn't reach Next.js. Four ways that happens.
1. A try/catch around the cookie read swallows it. This is the most common one in Supabase apps, because defensive try/catch around client construction looks like good hygiene:
// BUG: catches Next.js's own bailout signal
export default async function Page() {
let supabase;
try {
supabase = await createClient(); // awaits cookies() inside
} catch {
supabase = null;
}
// ...
}
Supabase's official helper has a try/catch too, but a narrow one — it wraps cookieStore.set(...), which throws a different, harmless error when called from a Server Component. Wrapping the await cookies() read itself is what breaks the build.
2. cookies() is called outside the render's async context. The docs are explicit that calling it inside a setTimeout, setInterval, or after an un-awaited promise detaches it from the call stack the context was bound to. A forgotten await on a helper that reads cookies produces exactly this.
3. The route is explicitly pinned to static. export const dynamic = "error" forces prerendering "by causing an error if any components use Request-time APIs or uncached data" — so a cookie read fails loudly, which is the setting working as designed.
force-static is the one to watch, because it does the opposite. Per the Next.js docs it forces cookies, headers() and useSearchParams() to return empty values. No error, no bailout, no build failure — getUser() simply sees no session and returns null on a page that is then prerendered once and served to everybody. If someone pinned a route force-static to make this error go away, they didn't fix it; they converted a loud build failure into a page that quietly believes every visitor is logged out.
4. You're on Next.js 13/14 with @supabase/auth-helpers. createServerComponentClient({ cookies }) passes the cookies function into a library that reads it at a moment Next.js isn't expecting. That package is deprecated — migrating to @supabase/ssr removes the whole class of problem, and it also clears up the base64-eyJ cookie parse error from the same era.
Worth saying plainly: on Next.js 15 and 16, cookies() is async and awaiting it in a layout or page opts the route into dynamic rendering on its own. If you're seeing this on a modern version, you're almost certainly looking at cause 1, 2, or 3 — not at a framework quirk.
The fix
Read cookies once, at the top, awaited, uncaught:
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
// No try/catch here. If this throws during prerender, that is the
// signal Next.js needs in order to render the route dynamically.
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (list) => {
try {
// Narrow: only the write, which legitimately throws in
// Server Components. Middleware owns the real write.
list.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {}
},
},
}
);
}
// app/dashboard/page.tsx
import { createClient } from "@/lib/supabase/server";
export default async function Dashboard() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// ...
}
export const dynamic = "force-dynamic" also makes the error go away, and it's the right answer when the route is genuinely per-user — a dashboard is never going to be static. It is the wrong answer when applied to a whole route group to silence a build, because it disables static optimization for pages that didn't need cookies at all. Fix the call site first; add force-dynamic deliberately, per route, second.
One version caveat: dynamic belongs to the pre-Cache-Components caching model. Next.js 16 removes dynamic, dynamicParams, revalidate and fetchCache when the cacheComponents flag is enabled, so if you've opted in, force-dynamic isn't available to you. There the shape changes anyway — calling cookies() outside a <Suspense> boundary prevents prerendering and produces a different error entirely, and the fix is a Suspense boundary around the part of the tree that needs the session, so the static shell still ships.
The fix that turns a build error into a security hole
There's a fourth reaction to this error, and it's the one worth watching for: strip cookie reads out of pages so the build goes green, and let middleware do all the authorization.
- Infomiddleware.ts:1
Middleware without a route matcher
Export an explicit config = { matcher: [...] } that lists exactly the protected paths, and confirm it covers every route that requires auth.
That middleware is doing real auth and exports no config.matcher, so it runs on everything and protects by string-matching pathname. Two problems compound. Path-prefix checks are easy to get subtly wrong — a route group, a rewrite, or a route added later outside /dashboard simply isn't covered, and nothing fails loudly. And middleware-only auth is a single checkpoint in front of pages that no longer verify anything themselves, which is precisely the arrangement CVE-2025-29927 made exploitable by letting a crafted header skip middleware entirely.
Middleware is the right place to refresh the session. It is not a sufficient place to authorize. Keep the getUser() call in the page or Server Action that touches the data, accept that the route renders dynamically, and let RLS backstop both — the checklist post walks through the layering.
Quick self-check
# 1. Any try/catch wrapping the cookies() read?
grep -rn -B 2 -A 2 "await cookies()" lib app | grep -i "try\|catch"
# 2. Still on the deprecated auth-helpers?
grep -rn "@supabase/auth-helpers" package.json app lib
# 3. Routes pinned to static that also read a session
grep -rn "dynamic = \"force-static\"\|dynamic = \"error\"" app
# 4. Does middleware export a matcher?
grep -n -A 5 "export const config" middleware.ts
Then run npm run build and read the route table it prints. Protected routes should be marked dynamic. A protected route marked static is a worse outcome than the build error — it means a prerendered page is being served to every user, and the session-shaped hole in it is filled by whoever rendered it first.
FAQ
Is force-dynamic bad?
No, it's just blunt. On a per-user dashboard it's correct and explicit. Applied broadly to make a build pass, it silently throws away static optimization for pages that never needed it.
Why did this start after I added generateStaticParams?
Because that opts the route into static generation at build time, and a cookie read is incompatible with rendering a page before any request exists. Either drop the cookie read from that route or drop the static params.
Can I read cookies in generateMetadata?
You can, and it makes the route dynamic just like reading them in the page. If the metadata doesn't actually depend on the user, don't — you're paying for dynamic rendering to produce an identical <title>.
Does this affect Route Handlers?
Route Handlers can be statically optimized too if they only handle GET and touch nothing dynamic. Reading cookies opts them out the same way. If yours fails at build, the cause list above applies unchanged.
I moved everything to a Client Component and the error stopped. It did, and check what you traded for it. Fetching with the anon key from the browser means your RLS policies are now the only thing standing between a user and the table — which is fine if they're correct, and a data leak if they're not.
Catch this before it ships — free
GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.