BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Blog/nextjs
Intermediate7 min read

Part 1 of Next.js Field Notes

One Line in My Root Layout Turned 158 Static Pages Into 0

Reading a cookie with cookies() in the Next.js root layout opts every route out of static rendering. What it cost this blog, measured, and how to fix a theme flash without it.

Mohsen Fallahnejad

Mohsen Fallahnejad

Nov 1, 20262 readsDiscuss

Dark mode on this blog has always had a tiny cheat in it. Before React loads, a little inline script reads your saved theme from localStorage and paints the page dark. It works, but it always felt like a hack. The server renders the page without knowing your theme, and a script patches it up in the browser.

The "proper" fix seemed obvious. Store the theme in a cookie, read it on the server, render the right class from the start. One line in the root layout:

tsx
// app/layout.tsx
import { cookies } from "next/headers";

export default async function RootLayout({ children }) {
  const theme = (await cookies()).get("theme")?.value;

  return (
    <html lang="en" className={theme === "dark" ? "dark" : undefined}>
      {/* ... */}
    </html>
  );
}

I tried it on a copy of this blog. Dark mode worked on the very first byte. No script, no patching. Then I ran next build.


The build output went all ƒ#

Before the change, the route table was mostly ○ and ●: pages rendered once at build time and served as files.

code
○ /                                    10m
○ /blog                                10m
● /blog/[...slug]   (46 posts)          5m
● /tags/[tag]       (102 topics)       10m
○ /about                                1h
○ /links

After the change:

code
ƒ /
ƒ /blog
ƒ /blog/[...slug]
ƒ /tags/[tag]
ƒ /about
ƒ /links

ƒ means "server-rendered on demand." I counted the prerendered pages in each build's manifest:

BuildPrerendered HTML pages
Before158
After one cookies() call0

Every post, every topic page, the home page, even /links — a page with no data at all — now renders from scratch on every request.


The wrong turn: "it's just reading a cookie"#

My mental model was that cookies() is a cheap lookup. Read a header, get a string, move on. And it is cheap.

The expensive part isn't reading the cookie. It's what reading it means.

A cookie belongs to one visitor. If a page's HTML depends on a cookie, then the HTML can differ for every visitor, so Next.js can't render it once at build time and hand the same file to everyone. The docs call cookies(), headers() and searchParams Request-time APIs: use one, and the route has to render at request time.

So where you call it decides how much of your app goes dynamic:

Where you read the cookie decides what goes dynamic

  1. 1

    A component calls cookies()

    cookies(), headers() and searchParams are all Request-time APIs

  2. 2

    Its route can't be prerendered

    The HTML now depends on who is asking

  3. 3

    Next.js renders that route on every request

    And the response is marked uncacheable

then it depends
Called in one page
  1. ↳only that route goes dynamic
Called in a nested layout
  1. ↳every route under that layout goes dynamic
Called in the root layout
  1. ↳every route in the app goes dynamic

The root layout wraps every page. One line there is one line in all of them.


What it actually cost#

I served both builds locally with next start and measured time to first byte: 30 requests per page after warm-up, median, on an Apple Silicon Mac with Next.js 16.4.

PageStatic buildCookie build
/links (no data)1.0 ms6.5 ms
A post page (Redis + Postgres)1.4 ms13.3 ms

Six to ten times slower, and that's the best case: my laptop is the server, so there's no network in between.

The bigger difference was in the response headers:

code
# static build
Cache-Control: s-maxage=300, stale-while-revalidate=31535700

# cookie build
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

The static page tells a CDN to keep it and serve it from the edge. The cookie page tells every cache in between to never store it. In production that means each visitor's request travels to wherever the server runs, waits for a full render, and travels back, instead of being answered by an edge server near them. Every one of those renders also costs compute.

All of that for a class name on <html>.


The fix: read the cookie in the browser#

The Next.js docs on preventing flash before hydration say it directly: reading the theme cookie in the root layout opts the whole app out of static rendering. Instead, keep the server output generic and read the preference in an inline script that runs before the first paint:

tsx
// app/layout.tsx — stays static
export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `(function(){try{
              var m = document.cookie.match(/(?:^|; )theme=([^;]*)/);
              if (m && m[1] === "dark") document.documentElement.classList.add("dark");
            }catch(e){}})()`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}
  • The browser runs the script while it parses <head>, before anything is painted, so there's no flash.
  • The server never reads the cookie, so all 158 pages stay prerendered and CDN-cacheable.
  • suppressHydrationWarning tells React the class on <html> was changed on purpose.

That "hack" I'd been embarrassed about? It was the recommended approach all along. I deleted the experiment and kept the script.


Make the build fail next time#

The scary part is how quiet this was. No warning, no error. Just a column of ƒ in a build log nobody reads.

You can turn it into a build error. Put this in the root layout:

tsx
// app/layout.tsx
export const dynamic = "error";

Now any route that touches a Request-time API stops the build:

code
Error: Route /_not-found with `dynamic = "error"` couldn't be rendered
statically because it used `cookies()`.

Pages that genuinely need to be dynamic opt back in explicitly. On this blog that's search (it reads searchParams) and the admin page:

tsx
// app/search/page.tsx
export const dynamic = "force-dynamic";

I tested both: without those two overrides the build fails on /admin; with them it passes and everything else stays ○. Going dynamic becomes a decision written in the file, not an accident in a shared layout.


If you use Cache Components#

Everything above is the default App Router behavior. This blog has since switched to Cache Components, and two things change there:

  • Route segment configs like dynamic are no longer allowed, so the dynamic = "error" guardrail goes away. Its replacement is ensureStatic (Next.js 16.4+), which reports an error when request-specific work stops a route from prerendering.
  • A Request-time API no longer has to make a whole route dynamic. You can move the read into a component wrapped in <Suspense>: the rest of the page prerenders and only that part streams in per request. Search on this blog works that way now.

But not for the theme. A value that sets a class on <html> in the root layout has no smaller child to wrap, and the migration guide points to the same fix: an inline <script> in <head>. The rule holds either way.


The rule#

Never read cookies or headers in a layout unless you want every page under it rendered on every request.

If one small piece of UI needs per-visitor data, read it as close to that piece as possible: in the one page that needs it, in a client component, or from the browser. Keep the shared layouts boring and static.


TL;DR#

  • cookies(), headers() and searchParams are Request-time APIs. Using one makes that route render on every request.
  • In the root layout, that's every route. On this blog it took prerendered pages from 158 to 0.
  • Measured locally, pages got 6–10× slower to respond, and the responses became Cache-Control: private, no-store, so CDNs can't cache them.
  • For a theme without a flash, read the cookie (or localStorage) in an inline <head> script, as the Next.js docs recommend.
  • Add export const dynamic = "error" to the root layout so an accidental Request-time API fails the build, and opt dynamic pages back in with force-dynamic.
Filed under#nextjs#app-router#performance#caching#cookies

Series

Next.js Field Notes

Tricks and traps from shipping Next.js App Router apps in production.

  1. 1One Line in My Root Layout Turned 158 Static Pages Into 0 · you are here
  2. 2Why your Server Action runs twice in development · coming soon
  3. 3Prefetching: the hidden cost of a long list of <Link>s · coming soon

New parts land here first — subscribe below to get them in your inbox.

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← OlderThe Leaderboard That Ranked 9 Points Above 100Newer →You Selected Rows 3, 8, 12, 15 and 20. JavaScript Checked 3, 1 and 8.

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

Next.js App Router Caching — Cheat Sheet

A compressed reference of Next.js caching layers and controls (App Router).

Sep 20, 20252 min read

CDN Explained: What It Is, How It Works, and How to Use It in Next.js

A CDN speeds up your app by serving assets from servers close to your users. Here's how it works and how Next.js takes advantage of it.

Jun 20, 20263 min read1

I Optimized a Button From O(n) to O(1). The Speed Didn't Matter.

A bug report, a route map, and three rounds of making it faster — a story about what Big O really measures, why constants win at small n, and why the best algorithm is sometimes deleting the question.

Oct 31, 202610 min read5

Get new posts in your inbox

No spam. Unsubscribe any time.

7 min left
Discuss👍 React
👍