Dark mode without the flash
---
title: "Dark mode without the flash"
published: true
description: "A dark mode should paint the reader's theme on the first frame, switch without re-rendering the page, and let one section disagree with the rest."
tags: react, css, darkmode, webdev
canonical_url: box-kite.dev
cover_image: box-kite.dev
series: Building with Box Kite
---
A dark mode should paint the reader's theme on the first frame, switch without re-rendering the page, and let one section disagree with the rest. Most manage one or two of those three jobs. This article builds all three with Box Kite, shows what each line costs, and is explicit about the one piece the library leaves to you.
_The examples here are screenshots; they run live in the original article._
The problem
Somebody opens your server-rendered page at night with their system set to dark. The HTML arrives, the browser paints it — white — and a moment later JavaScript runs, reads the preference and repaints everything dark. That flash is the most-reported dark-mode bug there is: the long-running Next.js discussion on it has dozens of replies, and a newer one opened because the usual fixes broke in the App Router.
The fixes people reach for each trade one problem for another:
- Keep the theme in React state and pass it to every component. The server cannot know the reader's preference, so it renders a guess; the client renders the truth; React reports a hydration mismatch and the page flashes anyway. Every switch re-renders the tree.
- Two sets of CSS variables, swapped by a class. This is the right mechanism, but it means a stylesheet of tokens maintained apart from the components that use them, and every new colour is an edit in two places.
- Hide the page until JavaScript has decided. No flash, and no content either, for as long as the bundle takes.
The model: a theme is a class on an ancestor
In Box Kite a theme is a prop on the element it styles:
import Box from '@box-kite/react';
export function Card() {
return (
<Box
p={6}
borderRadius={3}
b={1}
bgColor="white"
color="slate-900"
borderColor="slate-200"
theme={{ dark: { bgColor: 'slate-900', color: 'slate-100', borderColor: 'slate-800' } }}
>
The light colours are plain props, the dark ones are in theme.
</Box>
);
}
!The same card twice: under a light ancestor and under a dark one.
The library turns theme={{ dark: … }} into an ordinary CSS rule that applies when an ancestor carries the class dark. Nothing about it happens in React: there is no context to read and no prop to thread through. Which theme is showing is decided by one class on one element — usually <html> — and everything below follows it.
That one decision is what makes the other three jobs cheap.
Deciding the theme: Box.Theme
Box.Theme is the part that decides. Put it around the app once:
import Box from '@box-kite/react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Box.Theme use="global" storageKey="theme">
<App />
</Box.Theme>
</StrictMode>,
);
use="global" writes the theme onto <html>. With no choice stored, it follows prefers-color-scheme — and keeps following it, so a system that switches to dark at sunset takes the page with it. storageKey persists an explicit choice in localStorage, and a stored choice wins over the system from then on. A switch is a hook:
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
export function ThemeSwitch() {
const [theme, setTheme] = Box.Theme.useTheme();
return (
<Button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} props={{ 'aria-label': 'Switch theme' }}>
{theme === 'dark' ? 'Light' : 'Dark'}
</Button>
);
}
setTheme(null) forgets the stored choice and hands control back to the system.
What a switch costs. We measured it in Chrome with a render counter on every component: pressing the switch changed one class on <html>; the component that called useTheme() re-rendered, because it reads the theme; the page component, whose every colour changed, rendered exactly once in total — on mount. The colours change because the browser re-matches CSS, not because React re-rendered anything.
The first frame: a few lines you own
Box.Theme reads the system preference and the stored choice in the browser, after React starts. In a single-page app that is before anything is painted, so there is nothing to flash. A server-rendered or prerendered page is different: the HTML paints before any JavaScript runs, so the theme class has to be on <html> before that first paint — and only a script in the <head> runs early enough.
The library does not inject one for you; it is yours, and it is short. Ship the light theme in the HTML, and swap it before the body is painted:
<html lang="en" class="light" data-theme="light">
<head>
<script>
try {
var theme = localStorage.getItem('theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
var root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.setAttribute('data-theme', theme);
} catch (error) {}
</script>
</head>
</html>
The key is the same storageKey the provider uses, and the rule is the same — stored choice first, system second — so when React starts, Box.Theme resolves the theme the script already chose and leaves <html> alone. We checked that too: with the system set to dark, the class on <html> changed exactly once, from the shipped light to dark, before React ran, and never again; with light stored, the same page under a dark system kept light throughout. The try is for the reader whose browser blocks storage — they get the light theme the HTML shipped, which is also what a reader with JavaScript turned off sees.
Two variations:
- If your server already knows the choice — from a cookie, say — render the class on <html> on the server and skip the script. The repository's Next.js example renders <html className="dark" data-theme="dark"> in a Server Component, and the theme needs no provider at all there, because the rules only care about the class.
- If React renders the <html> element (Next.js and other frameworks do), the script changes an attribute React also rendered, so add suppressHydrationWarning to that element — the warning is about exactly the change you meant to make.
This is how box-kite.dev itself works: every page is prerendered in the light theme, and an inline script in the shell switches it before the first paint.
A section that stays light
Real pages have parts that ignore the page's theme: a code sample that is always dark, a print preview that is always light, a marketing block with its own colours. Because a theme is a class on an ancestor, a nested Box.Theme is simply a nearer ancestor:
import Box from '@box-kite/react';
export function PrintPreview() {
return (
<Box.Theme use="local" theme="light">
<Box p={6} bgColor="white" color="slate-900" theme={{ dark: { bgColor: 'slate-900', color: 'slate-100' } }}>
Light, whatever the page around it is doing.
</Box>
</Box.Theme>
);
}
!The theme switch, the card, and the print preview. With the page dark, the card follows it and the preview stays light.
The nested theme is a real boundary, not a second class competing with the first. Each theme rule is written with CSS @scope, from the theme's class down to the next element that declares a theme of its own, so the dark page's rules stop at the light section's edge — including for a property the inner section never mentions. In the measurement above, the light section kept its light background while the page around it was dark, and kept it through every switch.
The switch, animated
A theme change can cross-fade the whole page instead of snapping:
import Box from '@box-kite/react';
import type { ReactNode } from 'react';
export function Root({ children }: { children: ReactNode }) {
return (
<Box.Theme use="global" storageKey="theme" viewTransition>
{children}
</Box.Theme>
);
}
viewTransition runs the switch inside the browser's View Transitions API. Writing it by hand has a trap — the browser captures the page the moment your callback returns, before React has rendered the new state, so a hand-rolled version captures the old theme twice and nothing appears to animate — and the prop flushes the update inside the callback for you. A reader who asked for reduced motion gets the theme change without the cross-fade, and a browser without view transitions simply switches.
Charts follow too
The theme reaches markup the library did not render, through CSS variables. A chart library takes a colour string; give it a variable, and declare the variable per theme on a container:
import { ChartContainer } from '@box-kite/react/components/chart';
import { Line, LineChart, ResponsiveContainer } from 'recharts';
const months = [
{ month: 'Jan', revenue: 12 },
{ month: 'Feb', revenue: 18 },
{ month: 'Mar', revenue: 15 },
];
export function Revenue() {
return (
<ChartContainer series={['revenue']} height={60}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={months}>
<Line dataKey="revenue" stroke="var(--color-revenue)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartContainer>
);
}
!The revenue line, in the colour the container declares for the page's theme.
ChartContainer declares one --color-<series> per name, with a value for each theme, so the chart names no colour at all and its dark mode is the page's. The charts page shows a full example with a grid and axis labels.
The honest limits
- The pre-paint script is yours. For a server-rendered page, nothing in the library can run before the first paint; the few lines above are the whole of it, and they have to agree with the storageKey you gave Box.Theme.
- Theme rules need @scope: Chrome 118, Safari 17.4, Firefox 128 and later. In an older browser the theme rules are dropped — which is why the examples here write the light colours as plain props and only the dark ones in theme: an old browser then shows a working light page rather than an unstyled one.
- The system preference only knows light and dark. A theme is any class name — high-contrast, sepia, a brand theme — and theme={{ 'high-contrast': … }} works the same way, but choosing one of those is always an explicit setTheme.
Try it
- Theme setup — the reference for theme, Box.Theme and component themes.
- The playground — write a theme prop and see the rule it produces.
- npm install @box-kite/react, and wrap your app in Box.Theme.
---
_Originally published on box-kite.dev._ Box Kite is open source under MIT — if it is useful to you, a star on GitHub helps other people find it. Install it with npm install @box-kite/react.
Next.js 16 が publicRuntimeConfig を廃止予告 — 3 アプリへの影響確認と移行パターン
Cosoado Lab Blog 同時掲載予定: cosoado-lab.com
以前「Next.js で env var 1 つで 3 アプリの配色・機能を切り替えるジャンル設計」を書いた。Next.js 16 canary のリリースノートを眺めていたら Remove deprecated publicRuntimeConfig and serverRuntimeConfig というタイトルのコミットが目に入って、一瞬血の気が引いた。「あの記事の前提、詰んだ?」と思ったので、3 アプリ分のコードを確認しながら移行パターンを整理した。
TL;DR
Next.js 16.0.0 では削除は revert されており、deprecation 警告の追加に留まっている
canary ブランチのアップグレードガイドには「removed. Use environment variables instead」と明記されており、削除は時間の問題
移行パターン 2 種: ① ビルド時定数 → NEXT_PUBLIC_* ② 実行時読み取り → process.env + connection()
getConfig() を呼んでいるコードがあれば今すぐ grep して棚卸しした方がいい
publicRuntimeConfig が何のために存在したか
存在意義は「ビルド後に値を変えられる」こと。NEXT_PUBLIC_* はビルド時に bundle に静的インライン展開されるので、値を変えるには再ビルドが必要だ。getConfig() を使う runtime config は、サーバー起動時の process.env を変えるだけで差し替えられた。App Router 以降は Server Components が増えてこのニーズが別の方法で満たせるようになり、廃止に向かっている。
v16.0.0 の実際の状況
canary では PR #83944 で削除が一度 merge されたが、リリース直前の PR #84167 で revert された。v16.0.0 には削除ではなく PR #84168 で追加された deprecation 警告だけが入っている。
ただし canary ブランチのアップグレードガイドには:
serverRuntimeConfig and publicRuntimeConfig have been removed. Use environment variables instead.
と書いてある。v16.0.0 に間に合わなかっただけで削除の意志は変わっていない。次のマイナー更新か 16.x のどこかで壊れる。
私の 3 アプリは影響ゼロだった
SparMate・NetaPair・BoardLink は全部 NEXT_PUBLIC_GENRE=sparmate のような NEXT_PUBLIC_* パターンを使っていた。getConfig() はどこにも呼んでいなかった。
確認するまでの 10 分は冷や汗をかいたが、前回の記事を書いたときに「runtime config は使わない」と決めていたのが功を奏した。ちゃんと理由を持って設計しておくとこういうとき助かる。
移行パターン 2 種
パターン 1: ビルド時定数 → NEXT_PUBLIC_*
値はビルド時に bundle に埋め込まれる。
// Vercel の Environment Variables に NEXT_PUBLIC_API_BASE_URL を追加して置き換えるだけ
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
値を変えると再ビルドが必要。「デプロイ後に差し替えたい」ニーズがあればパターン 2 を使う。
パターン 2: 実行時読み取り → process.env + connection()
Server Components 専用。connection() を先に呼ぶことで「プリレンダリング時ではなく、リクエスト時に読む」ことが保証される。
import { connection } from 'next/server'
export default async function Page() {
await connection() // これ以降はリクエスト時に実行される
const apiUrl = process.env.API_BASE_URL
// ...
}
connection() なしだと、Next.js が静的プリレンダリング時に process.env を評価してしまう場合がある。serverRuntimeConfig の「起動後に差し替えたい」ユースケースはこれで代替できる。
やらかした話: connection() を Client Component で呼ぼうとした
移行パターンを確認しながら「connection() を useEffect の中で呼べばいいのでは?」と試した。コンパイルは通ったが、ブラウザのコンソールに「connection is not defined in client context」みたいなエラーが出た。
connection() は Server Components 専用だ。'use client' の中では呼べない。実行時読み取りが必要な値は Server Component で取得して props で渡すか、Server Action 経由にする必要がある。「async/await が書けるなら Server Component でも Client Component でも同じか」という油断がある。これは一番やった間違いで、自信満々に修正して PR を出したあとにレビューで気づいた。
まとめ
getConfig() を使っているコードは今すぐ grep -r "next/config" して棚卸しを
v16.0.0 は deprecation 警告だけだが、canary docs には「removed」とある
ビルド時定数でよければ → NEXT_PUBLIC_*
デプロイ後に変えたい実行時値は → Server Component + connection() + process.env
getConfig() を使った例は古いドキュメントやチュートリアルにまだ多い。次のアップグレードで詰まる前に確認しておく価値がある。
参照:
vercel/next.js #83944 — Remove deprecated publicRuntimeConfig and serverRuntimeConfig
vercel/next.js #84167 — Revert removal for v16.0.0
vercel/next.js #84168 — Add deprecation warning to Runtime config
Next.js v16 アップグレードガイド (canary)
SparMate — 格闘技の練習相手マッチング
NetaPair — お笑いの相方探し
BoardLink — ボドゲ・TRPG 仲間募集
Cosoado Lab
Next.js 16: Fixing "cookies() can't be read inside 'use cache'" (and Caching Public Data Without It)
You turn on Cache Components, add 'use cache' to a data function, and the build fails:
Error: Route "/broken": `cookies()` can't be read inside `"use cache"`. Read it outside the cached function and pass what you need as an argument.
Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache
at h (lib/server-client.ts:6:29)
at i (app/broken/page.tsx:7:24)
The confusing part is that your cached function might never call cookies() itself. In this example, the call is inside a helper that creates a "server client", the kind of cookie-aware factory that many auth and database SDKs ship for the App Router. The rule follows the call stack, so a cookie read anywhere below a 'use cache' boundary breaks it.
This post shows why that happens and the four patterns that fix it, each built and run on Next.js 16.4.3.
What you'll learn
- Why cookies(), headers() and searchParams are not allowed in 'use cache'
- How to cache public data when your SDK's default client reads cookies
- How to pass a cookie value into a cached function safely
- Where per-user data should go instead (and the Suspense rule that comes with it)
- When 'use cache: private' is the right tool, and what it does not do
Prerequisites and versions
- Next.js 16.4.3 with the App Router
- React 19.3.1
- TypeScript
- Cache Components enabled (below)
Since Next.js 16.0, cacheComponents is a top-level config option. The current docs recommend setting partialPrefetching explicitly alongside it. Leaving it unset logs a warning:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
Background: why the rule exists
A 'use cache' function's output is stored and reused across requests and users. Its cache key is built from the build ID, a hash of the function's identity, and its serializable arguments (see the use cache reference).
Cookies and headers aren't arguments. If a cached function could read them, the cache key wouldn't reflect them, and one user's cookie-dependent result could be served to someone else. So Next.js forbids request-time APIs inside the cached scope:
> Cached functions and components cannot access runtime APIs like cookies(), headers(), or searchParams, and the restriction follows the call stack: a helper the cached function calls that reads one of these fails the same way.
The docs also warn that on a dynamically rendered route this "can pass next build and fail under next start". Don't treat a green build as proof.
Reproducing the error
Here's a stand-in for a cookie-aware SDK factory. Real ones (for example, an auth or database "server client" for the App Router) usually take the cookie store so they can attach the user's session:
// lib/server-client.ts
import { cookies } from 'next/headers'
// Stand-in for a cookie-aware SDK factory such as an auth or database
// "server client". The important part: it calls cookies() internally.
export async function createServerClient() {
const cookieStore = await cookies()
const token = cookieStore.get('session')?.value ?? null
return {
async getPublicPosts() {
return [{ id: 1, title: 'Hello' }, { id: 2, title: 'World' }]
},
async getMyDrafts() {
if (!token) return []
return [{ id: 99, title: `Draft for ${token}` }]
},
}
}
And a page that caches "public" posts with it:
// app/broken/page.tsx
import { cacheLife } from 'next/cache'
import { createServerClient } from '@/lib/server-client'
async function getPublicPosts() {
'use cache'
cacheLife('hours')
const client = await createServerClient() // calls cookies() inside the cache scope
return client.getPublicPosts()
}
export default async function Page() {
const posts = await getPublicPosts()
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
next build fails with the error at the top of this post. The stack trace points to the cookies() call in the helper and to the cached function that called it. That's the fastest way to find the culprit in a real codebase.
The fix, in four patterns
All four patterns below live on one page. Together they built cleanly. Next.js marked the route as Partial Prerender (◐) with Revalidate 1h and Expire 1d, which come from the hours profile.
Pattern 1: Public data uses a client that never touches cookies
If the data is the same for everyone, it shouldn't go through a cookie-aware client at all. Create a separate client for public reads. With real SDKs, this usually means the plain client created with a public/anon key, not the App Router "server client" helper.
// lib/public-client.ts
// Stand-in for a cookie-free client (for example a plain SDK client created
// with a public/anon key). It never touches cookies() or headers().
export function createPublicClient() {
return {
async getPublicPosts() {
return [
{ id: 1, title: 'Hello' },
{ id: 2, title: 'World' },
]
},
}
}
// 1. Public data: cookie-free client, safe to share across all users.
async function getPublicPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const posts = await createPublicClient().getPublicPosts()
return { posts, generatedAt: new Date().toISOString() }
}
generatedAt is there to show the caching. With the page served by next start, two requests without cookies and one request with Cookie: locale=fr; session=alice all returned the same generatedAt value. The entry was filled during the build and reused for everyone.
Security note: Only do this for data that really is public. If your database enforces row-level security based on the user's session, a cookie-free client sees what an anonymous user sees. That's correct for public data and wrong for anything personal.
Pattern 2: Read the cookie outside and pass the value in
This is the pattern the error page recommends. The value you pass becomes part of the cache key, so each distinct value gets its own entry.
// 2. Data that depends on one small cookie value: read the cookie outside,
// pass the value in. The value becomes part of the cache key.
async function getGreeting(locale: 'en' | 'fr') {
'use cache'
cacheLife('days')
return locale === 'fr' ? 'Bonjour' : 'Hello'
}
async function Greeting() {
const raw = (await cookies()).get('locale')?.value
const locale = raw === 'fr' ? 'fr' : 'en'
return <p id="greeting">{await getGreeting(locale)}</p>
}
The response without cookies showed Hello. With Cookie: locale=fr it showed Bonjour.
Two design choices here matter:
- Normalize the value before passing it. raw === 'fr' ? 'fr' : 'en' limits the cache to two entries. If you pass a raw cookie straight through, any client can create a new cache entry by sending a random value.
- Prefer low-cardinality values such as locale, theme, plan tier or feature-flag variant. Passing a session token or user ID technically works (one entry per user), but at that point you're building a per-user cache. That's usually better handled by Pattern 3 or 4.
Pattern 3: Per-user data stays uncached, inside Suspense
Personal data, like "my drafts", can just stay dynamic. Use the cookie-aware client outside any cache scope:
// 3. Per-user data: not in a shared cache at all. Rendered at request time
// inside a Suspense boundary.
async function MyDrafts() {
const client = await createServerClient()
const drafts = await client.getMyDrafts()
return (
<ul id="drafts">
{drafts.map((d) => (
<li key={d.id}>{d.title}</li>
))}
</ul>
)
}
The full page puts it all together:
// app/fixed/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'
import { createPublicClient } from '@/lib/public-client'
import { createServerClient } from '@/lib/server-client'
// 1. Public data: cookie-free client, safe to share across all users.
async function getPublicPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const posts = await createPublicClient().getPublicPosts()
return { posts, generatedAt: new Date().toISOString() }
}
// 2. Data that depends on one small cookie value: read the cookie outside,
// pass the value in. The value becomes part of the cache key.
async function getGreeting(locale: 'en' | 'fr') {
'use cache'
cacheLife('days')
return locale === 'fr' ? 'Bonjour' : 'Hello'
}
async function Greeting() {
const raw = (await cookies()).get('locale')?.value
const locale = raw === 'fr' ? 'fr' : 'en'
return <p id="greeting">{await getGreeting(locale)}</p>
}
// 3. Per-user data: not in a shared cache at all. Rendered at request time
// inside a Suspense boundary.
async function MyDrafts() {
const client = await createServerClient()
const drafts = await client.getMyDrafts()
return (
<ul id="drafts">
{drafts.map((d) => (
<li key={d.id}>{d.title}</li>
))}
</ul>
)
}
export default async function Page() {
const { posts, generatedAt } = await getPublicPosts()
return (
<main>
<p id="generated">{generatedAt}</p>
<ul id="posts">
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
<Suspense fallback={<p>Loading greeting…</p>}>
<Greeting />
</Suspense>
<Suspense fallback={<p>Loading your drafts…</p>}>
<MyDrafts />
</Suspense>
</main>
)
}
The test results: without a session cookie the drafts list was empty. With session=alice it contained Draft for alice. The public posts and their timestamp were identical in both responses.
Why the Suspense boundaries matter. With Cache Components, reading cookies() outside <Suspense> blocks prerendering. This page fails the build:
// app/nosuspense/page.tsx — fails the build with Cache Components
import { cookies } from 'next/headers'
export default async function Page() {
const session = (await cookies()).get('session')?.value
return <p>{session ?? 'anonymous'}</p>
}
Error: Route "/nosuspense": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Wrapping the cookie-reading component in <Suspense> (as Greeting and MyDrafts are above) is the "[stream]" option. The static shell, which includes the cached public posts, is served right away, and the personal parts stream in.
Pattern 4: 'use cache: private' when you can't refactor
Sometimes the cookie read is buried in code you can't change easily. 'use cache: private' allows cookies(), headers() and searchParams inside the cached scope:
// app/private/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
async function getDashboard() {
'use cache: private'
cacheLife({ stale: 60 })
const session = (await cookies()).get('session')?.value ?? 'guest'
return { session, at: new Date().toISOString() }
}
async function Dashboard() {
const { session, at } = await getDashboard()
return <p id="dash">{session} @ {at}</p>
}
export default function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Dashboard />
</Suspense>
)
}
The important thing is what "private" means. From the docs: "In production, matching calls within one request can reuse the same result, but Next.js does not store it in a server cache across production requests." The client router can keep the output in browser memory for the stale time.
The test confirmed this. Under next start, consecutive requests with the same session=alice cookie got a different at timestamp every time. So 'use cache: private' deduplicates within a request and feeds the browser's router cache. It does not make repeated server requests cheaper. If you need that, use Patterns 1–3.
Other limits from the docs:
- connection() is still not allowed inside it.
- You can't configure custom cache handlers for it.
- Its stale time feeds the route's client cache. It must be at least 30 seconds for per-link prefetching and at least 5 minutes to be included in the route's App Shell.
Choosing a pattern
| Your data | Pattern |
| --- | --- |
| Same for everyone | 1: cookie-free client inside 'use cache' |
| Varies by a small, known set of values from a cookie/header | 2: read outside, normalize, pass as argument |
| Specific to the signed-in user | 3: uncached, in a component wrapped in <Suspense> |
| Per-user, and the cookie read can't be moved | 4: 'use cache: private', but no cross-request server cache |
Common mistakes and troubleshooting
- "My function doesn't call cookies()." Something it calls does. Check the stack trace. The first frame is the actual cookies() call, and the next frames show the path back to your cached function.
- The build passes, production throws. On dynamically rendered routes, the error shows up when the route runs. Exercise the route under next start before deploying.
- Passing the whole cookie store or headers() object. Arguments must be serializable, and class instances aren't. Extract the plain values you need.
- Passing unbounded values. Each distinct argument creates a cache entry. Validate and normalize anything that comes from the client.
- Using React.cache to smuggle values in. React.cache is isolated inside 'use cache' boundaries. Values set outside aren't visible inside. Use arguments.
- Module-level promise caches. The error page notes that a cached function can stall if it awaits a promise stored outside the function (for example, in a module-scoped Map). 'use cache' already deduplicates calls with the same arguments, so remove the extra layer.
- Draft Mode. You can read draftMode().isEnabled inside 'use cache', but cookies() and headers() are still off-limits, even in draft mode.
Limitations and alternatives
- With the default in-memory cache handler on serverless platforms, runtime cache entries "typically don't persist across requests", although build-time caching works normally. Self-hosted servers keep entries in memory. If you need a shared runtime cache, look at 'use cache: remote', which uses a platform-provided handler and has network and cost trade-offs.
- Cache entries don't survive a new deploy, because the build ID (or deploymentId) is part of the key.
- Cache Components needs the Node.js runtime and doesn't support the runtime route segment config. Remove export const runtime = ... from routes when you adopt it.
- Don't wait for plain 'use cache' to start accepting cookies()/headers(). A reply in discussion #82873 (August 2025) says "There's no plan to allow use cache to access cookies/headers" and suggests using 'use cache' as much as possible and the private variant only rarely. The current docs take the same position.
Conclusion
The error is Next.js protecting you from serving one user's data to another. Most of the time the fix is structural. Keep public reads on a cookie-free client inside 'use cache'. Read cookies at the edge of your component tree, normalize the values, and pass them in as arguments. Keep personal data uncached behind <Suspense>. Use 'use cache: private' only when you can't move the cookie read, and remember that it doesn't give you a cross-request server cache.
Next step: search your codebase for helpers that call cookies() or headers() and check whether any of them run below a 'use cache' boundary. Then run the affected routes under next start, not just next build.
References
- use cache directive: nextjs.org
- Error: Cannot access request data in use cache: nextjs.org
- use cache: private: nextjs.org
- use cache: remote: nextjs.org
- cacheComponents config: nextjs.org
- Caching guide: nextjs.org
- Blocking prerender error: nextjs.org
- Next.js discussion #82873, "Allow cookies() or headers() in use cache": next.js#82873
- Next.js 16.4 release post: nextjs.org
Prerendering a page per device, and the cache key that has to agree
---
title: "Prerendering a page per device, and the cache key that has to agree"
published: true
description: "A prerendered page in the Next.js App Router is one file per URL, and device specific markup needs two. Generating the extra routes keeps the public URL intact. Making your device split agree with the cache in front of it is the part nobody warns you about."
tags: nextjs, webdev, caching, performance
canonical_url: yhryshchanka.dev
ai_disclosure_level: some_ai
---
A marketing page with no per visitor content should be built once and handed
out as a file, which is what prerendering means here: the build writes the
HTML, and the server answers with it instead of rendering again. The page I had
to do this to was not quite static. Its footer is an accordion on a phone and a grid
on a laptop, and a few sections below it differ the same way. Two rendered
versions of one document have to exist at the same time.
The browser can make that choice by itself, which is the first thing to rule
out. A CSS media query renders both versions and hides one, so the same text
sits in the document twice and every visitor downloads the one they will never
see. JavaScript picks after the page has loaded, so the first paint is a guess
and the reader watches it change. Either way the page ships markup the device
in front of it will not use. Choosing at build time means one version per file,
and the HTML a crawler reads is the HTML a phone gets.
Everything below happens inside the application, in the Next.js App Router, on
version 15. The same split can live a layer above it instead.
Inside the application, that means two files and one URL. A shared cache in
front of it keys on that URL and nothing else, so on its own it cannot tell the
two files apart. What it can do instead is split its own storage by device, and
making my split agree with its split decided the shape of everything else.
The whole thing, before the explanation
Two prerendered files per page, one public URL, and a rewrite that picks
between them before the request reaches a route. Everything below this section
is the reasoning. This is the code, and it runs.
A page opts in with one export, and refuses to render at its own path, where no
device arrives:
app/terms-of-use/page.tsx
import Layout from '@/app/_layout/Layout'
import type { DeviceSegment } from '@/lib/device'
import { notFound } from 'next/navigation'
export const isDeviceBasedSSG = true
export const metadata = { title: 'Terms of use' }
export default function TermsPage({ device }: { device?: DeviceSegment }) {
if (!device) notFound()
return <Layout device={device}>Terms of use</Layout>
}
A script turns that marker into one route per device, plus the list of paths
that got one:
scripts/generate-device-routes.mjs
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
const DEVICES = ['desktop', 'mobile']
const SOURCE_DIR = 'app'
const OUT_DIR = join('app', '(generated)')
const PATHS_FILE = 'lib/cacheable-paths.generated.ts'
const MARKER = /export\s+const\s+isDeviceBasedSSG\s*=\s*true/
// A folder named `(group)` adds no segment to the URL. A folder named `_private` is out of
// routing altogether, so a page under one is not a route and must not get a wrapper.
const isGroup = (name) => name.startsWith('(')
const isPrivate = (name) => name.startsWith('_')
function findMarkedPages(dir, routePath = '') {
const entries = readdirSync(dir, { withFileTypes: true })
const here =
entries.some((entry) => entry.isFile() && entry.name === 'page.tsx') &&
MARKER.test(readFileSync(join(dir, 'page.tsx'), 'utf8'))
? [{ routePath: routePath || '/', dir }]
: []
return [
...here,
...entries
.filter(
(entry) =>
entry.isDirectory() && !isPrivate(entry.name) && join(dir, entry.name) !== OUT_DIR,
)
.flatMap((entry) =>
findMarkedPages(
join(dir, entry.name),
isGroup(entry.name) ? routePath : `${routePath}/${entry.name}`,
),
),
]
}
// The import path goes into a string literal and never into a comment: a directory
// name may carry a line terminator, which ends a // comment and drops the rest into
// code the build compiles.
const wrapper = (importPath, device, metadataExport) => `// Generated. Do not edit.
import Page${metadataExport ? `, { ${metadataExport} }` : ''} from ${JSON.stringify(importPath)}
export const dynamic = 'error'
${metadataExport ? `\nexport { ${metadataExport} }\n` : ''}
export default function GeneratedDeviceRoute() {
return <Page device="${device}" />
}
`
const marked = findMarkedPages(SOURCE_DIR)
for (const { routePath, dir } of marked) {
// A wrapper is a route at a fixed path, so it cannot carry a parameter, and it sits
// directly under the app root, so it cannot inherit a layout from the source's folder.
if (routePath.includes('[')) {
throw new Error(`${dir}: a device route cannot hold a dynamic segment (${routePath})`)
}
const ancestors = routePath.split('/').filter(Boolean).slice(0, -1)
for (let i = 0; i < ancestors.length; i += 1) {
const layout = join(SOURCE_DIR, ...ancestors.slice(0, i + 1), 'layout.tsx')
try {
readFileSync(layout)
throw new Error(`${dir}: ${layout} would not apply to the generated route`)
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
}
}
rmSync(OUT_DIR, { recursive: true, force: true })
for (const { routePath, dir } of marked) {
const source = readFileSync(join(dir, 'page.tsx'), 'utf8')
const metadataExport = /export\s+(async\s+)?function\s+generateMetadata/.test(source)
? 'generateMetadata'
: /export\s+const\s+metadata\b/.test(source)
? 'metadata'
: ''
for (const device of DEVICES) {
const outFile = join(OUT_DIR, device, routePath, 'page.tsx')
const importPath = relative(dirname(outFile), join(dir, 'page')).split('\\').join('/')
mkdirSync(dirname(outFile), { recursive: true })
writeFileSync(outFile, wrapper(importPath, device, metadataExport))
}
}
writeFileSync(
PATHS_FILE,
`// Generated. Do not edit.\nconst CACHEABLE_PATHS = ${JSON.stringify(
marked.map((page) => page.routePath),
)} as const\n\nexport default CACHEABLE_PATHS\n`,
)
console.log(`generated ${marked.length * DEVICES.length} routes for ${marked.length} pages`)
Run it before anything that reads the routes, and keep what it writes out of
the repository:
package.json
{
"scripts": {
"generate:routes": "node scripts/generate-device-routes.mjs",
"predev": "npm run generate:routes",
"prebuild": "npm run generate:routes",
"pretypecheck": "npm run generate:routes",
"pretest": "npm run generate:routes"
}
}
.gitignore
# Written by scripts/generate-device-routes.mjs.
app/(generated)/
lib/cacheable-paths.generated.ts
Middleware rewrites the public path into the generated route, so the visitor's
URL and the canonical tag never carry a device:
middleware.ts
import CACHEABLE_PATHS from '@/lib/cacheable-paths.generated'
import { deviceFromHeaders, isInternalDevicePath } from '@/lib/device'
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Nothing links to the internal form, so a request carrying one was typed or crawled.
if (isInternalDevicePath(pathname)) {
return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 })
}
if (!(CACHEABLE_PATHS as readonly string[]).includes(pathname)) return NextResponse.next()
return NextResponse.rewrite(
new URL(`/${deviceFromHeaders(request.headers)}${pathname}`, request.url),
)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
One device read that every page shares, and the guard the middleware just used:
lib/device.ts
import { userAgent } from 'next/server'
export const DEVICE_SEGMENTS = ['desktop', 'mobile'] as const
export type DeviceSegment = (typeof DEVICE_SEGMENTS)[number]
const LAYOUT_BY_CDN_TYPE: Record<string, DeviceSegment> = {
mobile: 'mobile',
tablet: 'mobile',
desktop: 'desktop',
}
export function deviceFromHeaders(headers: Headers): DeviceSegment {
const cdnType = headers.get('cf-device-type') ?? ''
// Own keys only: outside a cache rule this header is whatever the client sent, and
// `constructor` or `__proto__` would otherwise resolve to a member of the prototype.
if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType]
return userAgent({ headers }).device.type === 'mobile' ? 'mobile' : 'desktop'
}
export function isInternalDevicePath(pathname: string): boolean {
const [, first] = pathname.split('/')
return (DEVICE_SEGMENTS as readonly string[]).includes(first)
}
And the response headers, because a prerendered route otherwise advertises a
year of shared caching to whatever sits in front of it:
next.config.mjs
import CACHEABLE_PATHS from './lib/cacheable-paths.generated.ts'
/** @type {import('next').NextConfig} */
export default {
async headers() {
return CACHEABLE_PATHS.flatMap((path) => [
{
source: path,
has: [{ type: 'header', key: 'rsc' }],
headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
},
{
source: path,
missing: [
{ type: 'header', key: 'rsc' },
{ type: 'query', key: '_rsc' },
],
headers: [{ key: 'Cache-Control', value: 'public, max-age=0, s-maxage=3600' }],
},
])
},
}
That is the implementation, on Next 15 with the App Router. Next 16 renames the
file to proxy.ts and the function to proxy, which I have not run. The rest
of this article is why each of those decisions is the one that survived, and
the two failures none of the code shows on its face: a build that stays green
while nothing is prerendered, and a cache that hands one visitor's layout to
everybody else.
The cache key is the path, and nothing from the request
Next keeps the HTML it prerendered in what it calls the full route cache. The
key for that cache is the resolved route path. In 15.5 it is one line of
next/dist/server/base-server.js:
let resolvedUrlPathname = getRequestMeta(req, 'rewroteURL') || urlPathname
and getFilePath in the file system cache turns that same string into a path
on disk. Nothing else from the request reaches it. Next does send a Vary
header for whatever caches the response downstream, but its own route cache
does not consult one.
The query string does not help either. I measured that rather than reasoning
about it: with the page not reading searchParams, ?device=m, ?device=d
and no query at all came back byte for byte identical, all three hashing to
4005cda574c9, one cache entry answering every one of them. A page that reads searchParams is not prerendered at
all, and whether that is a build failure or a silent fallback to rendering on
demand is the subject of a later section.
Rewrites declared in next.config do not create routes either, they point at
routes that already exist, so they add no dimension to the key. I pointed one
at a prerendered route from beforeFiles and another from afterFiles, and
both source URLs came back identical to the route itself, all three hashing to
d0fa5dfdb35c. One entry, three ways in.
Two variants therefore mean two paths, and the only varying input a
prerendered render can see is a route parameter.
Where the shape came from
I did not invent this. The shape comes from a comment by a Next collaborator in
a discussion about
serving a light and a dark theme out of one cache:
> Prerender per known theme
>
> generateStaticParams returns the theme list. Pages live under [theme]/. A
> proxy rewrites /foo → /<cookie>/foo server-side, and redirects direct
> visits to /light or /dark back to the unprefixed path so themed URLs
> aren't shareable.
>
> Each route ends up with one cache slot per theme.
>
> HTML arrives themed. No FOUC, no JS required.
>
> Server can branch on theme, that is, RTL, different components, distinct meta.
Read it with devices in place of themes and it says four things. Make the
variant a route parameter. Move the pages that want variants under that
parameter's folder. Let generateStaticParams prerender one copy per value.
Have middleware, the function that runs before a request reaches a route,
rewrite the public URL into the variant so the visitor never sees it.
He names two costs:
> Switching theme is a server round trip, an actual navigation.
>
> Proxy on every request, between you and your CDN.
Neither reaches this case. Nobody switches devices, so there is no switch to
pay for. And middleware was already running on every request here, for locale
routing and for putting visitors into experiments, so its cost was on the bill
before this feature existed.
Two parts of that I did not want.
A dynamic segment in the first position costs more than the URLs it agrees to
match. Once the first segment is a parameter, every first level path is a valid
route, and the framework's own not found stops firing for a wrong one, because
from its side nothing is wrong. Something inside the tree has to decide which values are real
and call notFound() for the rest, which makes that check the only place the
legal values are written down. A reader who wants to know what the site accepts
can no longer look at the folders, they have to open the code. A whitelist does
not rescue it either, because Next has no fall through: once a route matches,
notFound() renders the not found boundary rather than handing the request to
the next candidate route.
The shared parent folder fails more quietly. Every page that wants a variant
has to move under it, and pages that cannot have one stay where they are. Say
articles/ holds two pages, one that can be cached and one that reads a cookie
and therefore cannot. The first moves under [theme]/ and the second does not,
so articles/ now exists in two places at once. Add a page, and remember the
second tree. Rename a folder, and remember it again. The build is green either
way, and the mistake shows up as a page that quietly stopped being prerendered.
So the parameter went away, and two literal folders replaced it, desktop/ and
mobile/, each holding a real page file. A folder matches its own name and
nothing else, so an unknown value now matches no route at all. That is what
generateStaticParams and dynamicParams were there to arrange, and what the
guard was there to catch when they failed, so all of it goes.
What literal folders do not remove is the duplicated tree. They move it:
desktop/ and mobile/ each hold a copy of every marked page. What changes is
who keeps the copies in step. Two trees a person edits is the objection above.
Two trees a script writes is a build step, because nobody edits them and nobody
reviews them. That is the whole argument for generating them.
What the generator writes
The whole opt-in is one export, at the page's normal path:
app/terms-of-use/page.tsx
export const isDeviceBasedSSG = true
The script writes two things from it: a route per device, and the list of paths
that got one. The list is a module rather than a directory scan, because the
same code has to work wherever the rewrite runs, and because deriving it from
the marker is what stops it drifting away from the folders it names.
Each generated route carries no logic of its own:
app/(generated)/desktop/terms-of-use/page.tsx
// Generated. Do not edit.
import Page, { metadata } from '../../../terms-of-use/page'
export const dynamic = 'error'
export { metadata }
export default function GeneratedDeviceRoute() {
return <Page device="desktop" />
}
The wrapper takes no props of its own on purpose. A page file is type checked
against the props Next passes a route, and a wrapper that declares anything
else fails that check. Metadata is re-exported, and a wrapper that forgets it
leaves the page with no title and no description, which is why the script reads
the source for a metadata export and emits the re-export only when it finds
one. The line that matters more than either of those is dynamic = 'error'.
The one line that turns a silent failure into a build failure
Nothing in a prerendered route's tree may read cookies(), headers() or
searchParams. That includes the layout and the not found boundary, which sit
in every route's tree. One such read anywhere puts the whole route back on per
request rendering, and the build stays green while it happens. The not found
page is the one that catches people out, and it is also the one page whose
device genuinely cannot be known, so it takes a fixed value rather than reading
for one.
dynamic = 'error' is what turns that class of mistake into a build failure,
and the failure names the API: `Route /desktop/terms-of-use with
dynamic = "error" couldn't be rendered statically because it used headers`.
The variant you serve has to be the variant the cache stored
Everything above gets you two prerendered files and a rewrite that picks one.
Put a shared cache in front and a new failure appears, because the cache stores
one body per bucket of its own choosing, and it chose that bucket with its own
classifier.
Cloudflare, for one, can split a cache rule by device type and then sends the
origin a CF-Device-Type header carrying the bucket it used. If middleware
picks the branch with a User Agent parser while the edge keys on the CDN's
answer, then for every user agent where the two disagree, whoever arrives first
decides what the entire bucket serves to everyone behind them.
They disagree on real traffic, and not on anything exotic. A native app's
networking stack and a reading device asked to behave like a desktop both
landed in one bucket at the edge and another in my parser. The particular
agents will age, so take the category instead of the list: anything whose user
agent does not describe the screen it draws on.
Whatever you parse with has to collapse to the buckets the cache actually
holds. Mine had a third answer, unknown, for anything it did not recognise,
and under two buckets a third answer is not a third variant, it is a coin toss
about which of the two documents a visitor gets. The read above has no third
branch for that reason. Every disagreement I found became a test case.
The fix is one server side read, used by every page, taking the CDN's answer
first and the user agent second:
lib/device.ts
export function deviceFromHeaders(headers: Headers): DeviceSegment {
const cdnType = headers.get('cf-device-type') ?? ''
// Own keys only: outside a cache rule this header is whatever the client sent, and
// `constructor` or `__proto__` would otherwise resolve to a member of the prototype.
if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType]
return parseUserAgent(headers.get('user-agent') ?? '').isMobile ? 'mobile' : 'desktop'
}
Tablets map to the mobile layout, which means three buckets hold two documents
and the tablet copy duplicates the mobile one. That is the price of letting the
cache own the classification.
The internal paths have to be unreachable
Nothing links to /desktop/terms-of-use, so a request carrying one was typed or
crawled. It is also a real route, and I checked what happens without the guard:
it answers 200 and serves the document. The same page at a second URL, with
whatever search weight the real one has split between them.
lib/device.ts
export function isInternalDevicePath(pathname: string): boolean {
const [, first] = pathname.split('/')
return (DEVICE_SEGMENTS as readonly string[]).includes(first)
}
The rewrite goes to /_not-found with an explicit status: 404. Without the
status it is a soft 404: the not found page at 200, which is the duplicate the
guard exists to prevent, wearing a different hat.
What I would keep
Keep the variant as a path segment, because nothing else reaches the key. Keep
literal folders rather than an enumerated dynamic segment, because a folder
cannot match a value it is not named after. Keep one device classifier for the
whole application, and if a shared cache sits in front, make it the cache's
classifier. Keep dynamic = 'error' on every variant route. Keep a check that
reads the prerender manifest rather than the route table, and a second one that
requests the URLs nobody should request and expects a 404.
Keep a check on the bytes themselves, which asks a running build for the same
page twice and compares what comes back: same bytes inside one device class,
different bytes between classes, and no change when the request carries
cookies. Take the cookie names from the application's own constants rather than
retyping them, or the check sends a cookie nothing reads and quietly passes on
two requests that both carried nothing.
Keep the generator, and know what you are buying with it. Two hand written
wrappers of four lines each do the same job today, for two cacheable pages. The
day somebody marks a third page, the wrappers and the path list have to follow,
and a person who forgets gets no error, just a page quietly rendering per
request again. A marker that generates both of them cannot be forgotten.
That is the only thing the script buys. The price is a prebuild step in front
of every command a developer runs, stack traces that point at files which are
not in the repository, an error that names a wrapper when the mistake is in the
source page, a tree that can be stale, and a script that writes paths into code
the build then compiles. For two pages it is a bad trade. For a site where
anybody can mark a page, I would make it again.
The generated tree is in .gitignore, and that is the call in all of this I am
least settled about. It keeps the repository honest about what a person wrote.
It also means nothing in the repository shows a reader what actually gets built,
and a stack trace can point at a file they cannot open.
The code here is a rewrite of what shipped, generic enough to drop into any
project. To be sure the rewrite is more than a sketch I built a clean Next 15.5
app out of exactly these files and drove it, and the build output, the response
headers and the hashes above come from that app. What a local app cannot show
is how two classifiers disagree on real traffic, and that part comes from the
application the design shipped in.
1 reaction · 2 comments
create-next-app でPages Routerにする選択肢を無くすらしい、ほかフレームワークを使えってことなのかな
next.js#100850
7 likes · 534 views
Next.js 16 で images.domains 廃止 — 3 アプリを remotePatterns に書き直す
Cosoado Lab Blog 同時掲載予定: cosoado-lab.com
Next.js 16 に上げた月曜の朝、build ログに DeprecationWarning: The "images.domains" configuration is deprecated が並んだ。v14 のドキュメントには deprecated と書いてあったが、runtime warning が出ていなかったので 2 年近く放置し続けた報いだ。Next.js 14 のリリースは 2024 年だから、計算したらそうなる。なんで今まで気づかなかったかは聞かないでほしい。SparMate・NetaPair・BoardLink の next.config.ts を一気に直した。ついでに images.localPatterns という別の罠も踏んだので両方まとめておく。
TL;DR
images.domains → Next.js 16 で deprecation warning。移行先は images.remotePatterns(プロトコル・パスまで絞れる)
query string 付きローカル URL <Image src="/api/photo?id=1" /> は Next.js 16 からエラー。images.localPatterns で許可が必要
参照: PR #84625 / PR #84406
images.domains が deprecated になった理由
images.domains の問題は許可の粒度が粗すぎることだ。ホスト名だけを書くと全パス・HTTP と HTTPS の両方が許可される。「このパスの HTTPS だけ許可したい」という絞り方ができない。
?url=lh3.googleusercontent.com を渡せばそのホストからなら何でも画像最適化エンドポイントを通過してしまう。Next.js 12.3 で追加された remotePatterns はプロトコル・ホスト名・パスをそれぞれ絞れる。domains はその時点で役目を終えていた。
remotePatterns への移行
// before
const config: NextConfig = {
images: {
domains: ['avatars.githubusercontent.com', 'lh3.googleusercontent.com'],
},
}
// after
import type { NextConfig } from 'next'
const config: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
pathname: '/u/',
},
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
pathname: '/a/',
},
],
},
}
export default config
protocol: 'https' で HTTP リクエストをはじき、pathname でパスを絞る。/** は任意のサブパスにマッチするワイルドカードだ。import type { NextConfig } を書いておくと remotePatterns のプロパティが TypeScript で補完される。
1 行書き換えでは済まないが、使っているドメインを書き出すだけなので 3 アプリ分でも 15 分かからない。
localPatterns の新要件(Next.js 16 breaking change)
images.domains の警告に気を取られていたら、もう 1 つ踏んだ。
Next.js 16 から、<Image> に query string 付きのローカル URL を渡すには images.localPatterns での許可が必要(PR #84406)。
// Next.js 16 から runtime error になる
<Image src={/api/user-photo?userId=${userId}} width={64} height={64} alt="avatar" />
// Error: ... is using a query string which is not configured in images.localPatterns.
デフォルトの localPatterns は { pathname: '/**', search: '' } — search が空文字列、つまり「query なし」しか通さない。修正:
const config: NextConfig = {
images: {
localPatterns: [
{
pathname: '/api/user-photo',
// search を省略すると任意の query を許可
},
],
},
}
opengraph-image.tsx + ImageResponse の OG 画像生成パターン(前の記事)は <Image> を経由しないので影響なし。admin 画面で <Image src="/api/og?preview=true" /> のように query 付きローカル URL を渡している場合は対象になる。
これは一番やった失敗です
remotePatterns に移行するとき、最初に書いたのがこれだった:
remotePatterns: [
{
protocol: 'https',
hostname: '**', // ← 全ホスト許可。domains と何も変わらない
},
],
「warning が消えた、動いた、commit」と半自動的にやった。セルフレビューで気づいたのが commit の 10 分後で、push 前だったのは不幸中の幸いだ。hostname: '**' は全ドメインを許可するワイルドカードで、images.domains: ['*'] でホワイトリスト全開放するのと同じことになる。remotePatterns の意味は「このホストのこのパスだけ通す」であって、wildcard 全開放は趣旨を外れている。
まとめ
images.domains → images.remotePatterns に移行。protocol + pathname まで絞ることでセキュリティが上がる
query string 付きローカル <Image> は images.localPatterns での許可が必要(Next.js 16 breaking change)
hostname: '**' で移行した気になるのが一番よくないパターン
1 アプリ 10 分の作業。warning を「後でやる」で積んでいくとバージョンアップのたびに地雷が増える。
この記事の設定を実際に使っているプロダクト:
SparMate — スポーツマッチングアプリ
NetaPair — ネタ系交流マッチングアプリ
BoardLink — ボードゲームマッチングアプリ
Cosoado Lab — 個人開発ラボ
next.js: The Hottest JavaScript Repo on GitHub Right Now #Shorts
In this video, we break down vercel/next.js - one of the hottest open-source projects on GitHub right now.
The React Framework. It has already earned 143,096 stars on GitHub and is written primarily in JavaScript. We look at what it does, why developers are paying attention to it right now, and who should try it.
📂 Repo: vercel/next.js
🌐 Stars: 143,096 | Language: JavaScript | Forks: 35,275
🔔 Subscribe for daily picks of the hottest GitHub repos: / @reporadar
#github #opensource #programming
keywords: coding, developer tools, github, opensource, programming, software, tech
#Shorts
20 views
eslint-plugin-reactから@eslint-react/eslint-pluginに移行
Reactのlintルールをeslint-plugin-reactから@eslint-react/eslint-pluginに移行した。
移行の理由
eslint-plugin-reactがESLint 10へのブロッカーになっていたため。ESLint 10では非推奨のAPIの削除があったが、eslint-plugin-reactでは未対応であった。ESLint 10への対応PR自体は7月末出ていたが、記事執筆時点(2026/10/24)では未マージである。
PR: eslint-plugin-react#4022
Next.jsのプロジェクトでは、プロジェクト側で互換レイヤーを作成することによって使用を継続していた。ただ、AIである程度簡単にできるとはいえESLintの変更に応じてパッチが必要になるのも面倒なので移行した。
Next.jsの変更: github.com/vercel/next.js/commit/907721a66e8b4174…
移行先
@eslint-react/eslint-pluginのリポジトリ: Rel1cx/eslint-react
ルールについては全てのeslint-plugin-reactのルールをカバーしているわけではないので、移行の際には注意が必要である。
ルールの対応表: eslint-react.xyz
個人のリポジトリであるため、会社のルールによっては使えない可能性もある。ESLint Communityへの招待も来ているが、実際に今後移管されるかは未定である。
招待: eslint-react#1950
next.js: Why 0 developers just starred next.js
⚡ Tech breakdowns, AI news, and future trends. Hit subscribe to stay ahead.
next.js is
With 0 stars on GitHub, it's one of the fastest-growing tools in its category.
🔗 GitHub: vercel/next.js
🌐 Website: letsmakeourworld.vercel.app
🎁 next.js Starter Kit — setup guide + templates
💬 Comment "FUTURE58" below and we'll send you the link to try it yourself!
🔔 Subscribe for weekly deep dives on trending open source tools.
🚀 Build with HyperAutomationLabs:
Website: letsmakeourworld.vercel.app
Source/demo repo: vercel/next.js
🔔 Subscribe for more tech content from HyperAutomationLabs.
💬 Comment "BUILD78" below to get next.js Starter Kit — setup guide + templates or tell us what use case you want us to build next.
📌 Timestamps:
0:00 Introduction
#HyperAutomationLabs #tech #AI #Automation #BuildInPublic
---
⚠️ ARCHIVAL NOTICE & AGENT PRODUCTION DISCLAIMER:
This video was researched, written, voiced, and edited autonomously by an AI documentary agent (ProjectNexus Autonomous Media Engine). Content is synthesized from public domain historical archives, declassified records, investigative news reports, and open cold case dossiers for educational, documentary, and historical preservation purposes. Living individuals mentioned are presumed innocent unless proven guilty in a court of law.
1 comment · 5 views
next.js: The Hottest JavaScript Repo on GitHub Right Now
In this video, we break down vercel/next.js - one of the hottest open-source projects on GitHub right now.
The React Framework. It has already earned 143,096 stars on GitHub and is written primarily in JavaScript. We look at what it does, why developers are paying attention to it right now, and who should try it.
📂 Repo: vercel/next.js
🌐 Stars: 143,096 | Language: JavaScript | Forks: 35,275
🔔 Subscribe for daily picks of the hottest GitHub repos: / @reporadar
#github #opensource #programming
keywords: coding, developer tools, github, opensource, programming, software, tech
2 views
Mentions
Dark mode without the flash --- title: "Dark mode without the flash" published: true description: "A dark mode should paint the reader's theme on the first frame, switch without re-rendering the page, and let one section disagree with the rest." tags: react, css, darkmode, webdev canonical_url: box-kite.dev cover_image: box-kite.dev series: Building with Box Kite --- A dark mode should paint the reader's theme on the first frame, switch without re-rendering the page, and let one section disagree with the rest. Most manage one or two of those three jobs. This article builds all three with Box Kite, shows what each line costs, and is explicit about the one piece the library leaves to you. _The examples here are screenshots; they run live in the original article._ The problem Somebody opens your server-rendered page at night with their system set to dark. The HTML arrives, the browser paints it — white — and a moment later JavaScript runs, reads the preference and repaints everything dark. That flash is the most-reported dark-mode bug there is: the long-running Next.js discussion on it has dozens of replies, and a newer one opened because the usual fixes broke in the App Router. The fixes people reach for each trade one problem for another: - Keep the theme in React state and pass it to every component. The server cannot know the reader's preference, so it renders a guess; the client renders the truth; React reports a hydration mismatch and the page flashes anyway. Every switch re-renders the tree. - Two sets of CSS variables, swapped by a class. This is the right mechanism, but it means a stylesheet of tokens maintained apart from the components that use them, and every new colour is an edit in two places. - Hide the page until JavaScript has decided. No flash, and no content either, for as long as the bundle takes. The model: a theme is a class on an ancestor In Box Kite a theme is a prop on the element it styles: import Box from '@box-kite/react'; export function Card() { return ( <Box p={6} borderRadius={3} b={1} bgColor="white" color="slate-900" borderColor="slate-200" theme={{ dark: { bgColor: 'slate-900', color: 'slate-100', borderColor: 'slate-800' } }} > The light colours are plain props, the dark ones are in theme. </Box> ); } !The same card twice: under a light ancestor and under a dark one. The library turns theme={{ dark: … }} into an ordinary CSS rule that applies when an ancestor carries the class dark. Nothing about it happens in React: there is no context to read and no prop to thread through. Which theme is showing is decided by one class on one element — usually <html> — and everything below follows it. That one decision is what makes the other three jobs cheap. Deciding the theme: Box.Theme Box.Theme is the part that decides. Put it around the app once: import Box from '@box-kite/react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')!).render( <StrictMode> <Box.Theme use="global" storageKey="theme"> <App /> </Box.Theme> </StrictMode>, ); use="global" writes the theme onto <html>. With no choice stored, it follows prefers-color-scheme — and keeps following it, so a system that switches to dark at sunset takes the page with it. storageKey persists an explicit choice in localStorage, and a stored choice wins over the system from then on. A switch is a hook: import Box from '@box-kite/react'; import Button from '@box-kite/react/components/button'; export function ThemeSwitch() { const [theme, setTheme] = Box.Theme.useTheme(); return ( <Button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} props={{ 'aria-label': 'Switch theme' }}> {theme === 'dark' ? 'Light' : 'Dark'} </Button> ); } setTheme(null) forgets the stored choice and hands control back to the system. What a switch costs. We measured it in Chrome with a render counter on every component: pressing the switch changed one class on <html>; the component that called useTheme() re-rendered, because it reads the theme; the page component, whose every colour changed, rendered exactly once in total — on mount. The colours change because the browser re-matches CSS, not because React re-rendered anything. The first frame: a few lines you own Box.Theme reads the system preference and the stored choice in the browser, after React starts. In a single-page app that is before anything is painted, so there is nothing to flash. A server-rendered or prerendered page is different: the HTML paints before any JavaScript runs, so the theme class has to be on <html> before that first paint — and only a script in the <head> runs early enough. The library does not inject one for you; it is yours, and it is short. Ship the light theme in the HTML, and swap it before the body is painted: <html lang="en" class="light" data-theme="light"> <head> <script> try { var theme = localStorage.getItem('theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); var root = document.documentElement; root.classList.remove('light', 'dark'); root.classList.add(theme); root.setAttribute('data-theme', theme); } catch (error) {} </script> </head> </html> The key is the same storageKey the provider uses, and the rule is the same — stored choice first, system second — so when React starts, Box.Theme resolves the theme the script already chose and leaves <html> alone. We checked that too: with the system set to dark, the class on <html> changed exactly once, from the shipped light to dark, before React ran, and never again; with light stored, the same page under a dark system kept light throughout. The try is for the reader whose browser blocks storage — they get the light theme the HTML shipped, which is also what a reader with JavaScript turned off sees. Two variations: - If your server already knows the choice — from a cookie, say — render the class on <html> on the server and skip the script. The repository's Next.js example renders <html className="dark" data-theme="dark"> in a Server Component, and the theme needs no provider at all there, because the rules only care about the class. - If React renders the <html> element (Next.js and other frameworks do), the script changes an attribute React also rendered, so add suppressHydrationWarning to that element — the warning is about exactly the change you meant to make. This is how box-kite.dev itself works: every page is prerendered in the light theme, and an inline script in the shell switches it before the first paint. A section that stays light Real pages have parts that ignore the page's theme: a code sample that is always dark, a print preview that is always light, a marketing block with its own colours. Because a theme is a class on an ancestor, a nested Box.Theme is simply a nearer ancestor: import Box from '@box-kite/react'; export function PrintPreview() { return ( <Box.Theme use="local" theme="light"> <Box p={6} bgColor="white" color="slate-900" theme={{ dark: { bgColor: 'slate-900', color: 'slate-100' } }}> Light, whatever the page around it is doing. </Box> </Box.Theme> ); } !The theme switch, the card, and the print preview. With the page dark, the card follows it and the preview stays light. The nested theme is a real boundary, not a second class competing with the first. Each theme rule is written with CSS @scope, from the theme's class down to the next element that declares a theme of its own, so the dark page's rules stop at the light section's edge — including for a property the inner section never mentions. In the measurement above, the light section kept its light background while the page around it was dark, and kept it through every switch. The switch, animated A theme change can cross-fade the whole page instead of snapping: import Box from '@box-kite/react'; import type { ReactNode } from 'react'; export function Root({ children }: { children: ReactNode }) { return ( <Box.Theme use="global" storageKey="theme" viewTransition> {children} </Box.Theme> ); } viewTransition runs the switch inside the browser's View Transitions API. Writing it by hand has a trap — the browser captures the page the moment your callback returns, before React has rendered the new state, so a hand-rolled version captures the old theme twice and nothing appears to animate — and the prop flushes the update inside the callback for you. A reader who asked for reduced motion gets the theme change without the cross-fade, and a browser without view transitions simply switches. Charts follow too The theme reaches markup the library did not render, through CSS variables. A chart library takes a colour string; give it a variable, and declare the variable per theme on a container: import { ChartContainer } from '@box-kite/react/components/chart'; import { Line, LineChart, ResponsiveContainer } from 'recharts'; const months = [ { month: 'Jan', revenue: 12 }, { month: 'Feb', revenue: 18 }, { month: 'Mar', revenue: 15 }, ]; export function Revenue() { return ( <ChartContainer series={['revenue']} height={60}> <ResponsiveContainer width="100%" height="100%"> <LineChart data={months}> <Line dataKey="revenue" stroke="var(--color-revenue)" strokeWidth={2} dot={false} /> </LineChart> </ResponsiveContainer> </ChartContainer> ); } !The revenue line, in the colour the container declares for the page's theme. ChartContainer declares one --color-<series> per name, with a value for each theme, so the chart names no colour at all and its dark mode is the page's. The charts page shows a full example with a grid and axis labels. The honest limits - The pre-paint script is yours. For a server-rendered page, nothing in the library can run before the first paint; the few lines above are the whole of it, and they have to agree with the storageKey you gave Box.Theme. - Theme rules need @scope: Chrome 118, Safari 17.4, Firefox 128 and later. In an older browser the theme rules are dropped — which is why the examples here write the light colours as plain props and only the dark ones in theme: an old browser then shows a working light page rather than an unstyled one. - The system preference only knows light and dark. A theme is any class name — high-contrast, sepia, a brand theme — and theme={{ 'high-contrast': … }} works the same way, but choosing one of those is always an explicit setTheme. Try it - Theme setup — the reference for theme, Box.Theme and component themes. - The playground — write a theme prop and see the rule it produces. - npm install @box-kite/react, and wrap your app in Box.Theme. --- _Originally published on box-kite.dev._ Box Kite is open source under MIT — if it is useful to you, a star on GitHub helps other people find it. Install it with npm install @box-kite/react.
Next.js 16 が publicRuntimeConfig を廃止予告 — 3 アプリへの影響確認と移行パターン Cosoado Lab Blog 同時掲載予定: cosoado-lab.com 以前「Next.js で env var 1 つで 3 アプリの配色・機能を切り替えるジャンル設計」を書いた。Next.js 16 canary のリリースノートを眺めていたら Remove deprecated publicRuntimeConfig and serverRuntimeConfig というタイトルのコミットが目に入って、一瞬血の気が引いた。「あの記事の前提、詰んだ?」と思ったので、3 アプリ分のコードを確認しながら移行パターンを整理した。 TL;DR Next.js 16.0.0 では削除は revert されており、deprecation 警告の追加に留まっている canary ブランチのアップグレードガイドには「removed. Use environment variables instead」と明記されており、削除は時間の問題 移行パターン 2 種: ① ビルド時定数 → NEXT_PUBLIC_* ② 実行時読み取り → process.env + connection() getConfig() を呼んでいるコードがあれば今すぐ grep して棚卸しした方がいい publicRuntimeConfig が何のために存在したか 存在意義は「ビルド後に値を変えられる」こと。NEXT_PUBLIC_* はビルド時に bundle に静的インライン展開されるので、値を変えるには再ビルドが必要だ。getConfig() を使う runtime config は、サーバー起動時の process.env を変えるだけで差し替えられた。App Router 以降は Server Components が増えてこのニーズが別の方法で満たせるようになり、廃止に向かっている。 v16.0.0 の実際の状況 canary では PR #83944 で削除が一度 merge されたが、リリース直前の PR #84167 で revert された。v16.0.0 には削除ではなく PR #84168 で追加された deprecation 警告だけが入っている。 ただし canary ブランチのアップグレードガイドには: serverRuntimeConfig and publicRuntimeConfig have been removed. Use environment variables instead. と書いてある。v16.0.0 に間に合わなかっただけで削除の意志は変わっていない。次のマイナー更新か 16.x のどこかで壊れる。 私の 3 アプリは影響ゼロだった SparMate・NetaPair・BoardLink は全部 NEXT_PUBLIC_GENRE=sparmate のような NEXT_PUBLIC_* パターンを使っていた。getConfig() はどこにも呼んでいなかった。 確認するまでの 10 分は冷や汗をかいたが、前回の記事を書いたときに「runtime config は使わない」と決めていたのが功を奏した。ちゃんと理由を持って設計しておくとこういうとき助かる。 移行パターン 2 種 パターン 1: ビルド時定数 → NEXT_PUBLIC_* 値はビルド時に bundle に埋め込まれる。 // Vercel の Environment Variables に NEXT_PUBLIC_API_BASE_URL を追加して置き換えるだけ const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL 値を変えると再ビルドが必要。「デプロイ後に差し替えたい」ニーズがあればパターン 2 を使う。 パターン 2: 実行時読み取り → process.env + connection() Server Components 専用。connection() を先に呼ぶことで「プリレンダリング時ではなく、リクエスト時に読む」ことが保証される。 import { connection } from 'next/server' export default async function Page() { await connection() // これ以降はリクエスト時に実行される const apiUrl = process.env.API_BASE_URL // ... } connection() なしだと、Next.js が静的プリレンダリング時に process.env を評価してしまう場合がある。serverRuntimeConfig の「起動後に差し替えたい」ユースケースはこれで代替できる。 やらかした話: connection() を Client Component で呼ぼうとした 移行パターンを確認しながら「connection() を useEffect の中で呼べばいいのでは?」と試した。コンパイルは通ったが、ブラウザのコンソールに「connection is not defined in client context」みたいなエラーが出た。 connection() は Server Components 専用だ。'use client' の中では呼べない。実行時読み取りが必要な値は Server Component で取得して props で渡すか、Server Action 経由にする必要がある。「async/await が書けるなら Server Component でも Client Component でも同じか」という油断がある。これは一番やった間違いで、自信満々に修正して PR を出したあとにレビューで気づいた。 まとめ getConfig() を使っているコードは今すぐ grep -r "next/config" して棚卸しを v16.0.0 は deprecation 警告だけだが、canary docs には「removed」とある ビルド時定数でよければ → NEXT_PUBLIC_* デプロイ後に変えたい実行時値は → Server Component + connection() + process.env getConfig() を使った例は古いドキュメントやチュートリアルにまだ多い。次のアップグレードで詰まる前に確認しておく価値がある。 参照: vercel/next.js #83944 — Remove deprecated publicRuntimeConfig and serverRuntimeConfig vercel/next.js #84167 — Revert removal for v16.0.0 vercel/next.js #84168 — Add deprecation warning to Runtime config Next.js v16 アップグレードガイド (canary) SparMate — 格闘技の練習相手マッチング NetaPair — お笑いの相方探し BoardLink — ボドゲ・TRPG 仲間募集 Cosoado Lab
Next.js 16: Fixing "cookies() can't be read inside 'use cache'" (and Caching Public Data Without It) You turn on Cache Components, add 'use cache' to a data function, and the build fails: Error: Route "/broken": `cookies()` can't be read inside `"use cache"`. Read it outside the cached function and pass what you need as an argument. Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache at h (lib/server-client.ts:6:29) at i (app/broken/page.tsx:7:24) The confusing part is that your cached function might never call cookies() itself. In this example, the call is inside a helper that creates a "server client", the kind of cookie-aware factory that many auth and database SDKs ship for the App Router. The rule follows the call stack, so a cookie read anywhere below a 'use cache' boundary breaks it. This post shows why that happens and the four patterns that fix it, each built and run on Next.js 16.4.3. What you'll learn - Why cookies(), headers() and searchParams are not allowed in 'use cache' - How to cache public data when your SDK's default client reads cookies - How to pass a cookie value into a cached function safely - Where per-user data should go instead (and the Suspense rule that comes with it) - When 'use cache: private' is the right tool, and what it does not do Prerequisites and versions - Next.js 16.4.3 with the App Router - React 19.3.1 - TypeScript - Cache Components enabled (below) Since Next.js 16.0, cacheComponents is a top-level config option. The current docs recommend setting partialPrefetching explicitly alongside it. Leaving it unset logs a warning: // next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, } export default nextConfig Background: why the rule exists A 'use cache' function's output is stored and reused across requests and users. Its cache key is built from the build ID, a hash of the function's identity, and its serializable arguments (see the use cache reference). Cookies and headers aren't arguments. If a cached function could read them, the cache key wouldn't reflect them, and one user's cookie-dependent result could be served to someone else. So Next.js forbids request-time APIs inside the cached scope: > Cached functions and components cannot access runtime APIs like cookies(), headers(), or searchParams, and the restriction follows the call stack: a helper the cached function calls that reads one of these fails the same way. The docs also warn that on a dynamically rendered route this "can pass next build and fail under next start". Don't treat a green build as proof. Reproducing the error Here's a stand-in for a cookie-aware SDK factory. Real ones (for example, an auth or database "server client" for the App Router) usually take the cookie store so they can attach the user's session: // lib/server-client.ts import { cookies } from 'next/headers' // Stand-in for a cookie-aware SDK factory such as an auth or database // "server client". The important part: it calls cookies() internally. export async function createServerClient() { const cookieStore = await cookies() const token = cookieStore.get('session')?.value ?? null return { async getPublicPosts() { return [{ id: 1, title: 'Hello' }, { id: 2, title: 'World' }] }, async getMyDrafts() { if (!token) return [] return [{ id: 99, title: `Draft for ${token}` }] }, } } And a page that caches "public" posts with it: // app/broken/page.tsx import { cacheLife } from 'next/cache' import { createServerClient } from '@/lib/server-client' async function getPublicPosts() { 'use cache' cacheLife('hours') const client = await createServerClient() // calls cookies() inside the cache scope return client.getPublicPosts() } export default async function Page() { const posts = await getPublicPosts() return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> } next build fails with the error at the top of this post. The stack trace points to the cookies() call in the helper and to the cached function that called it. That's the fastest way to find the culprit in a real codebase. The fix, in four patterns All four patterns below live on one page. Together they built cleanly. Next.js marked the route as Partial Prerender (◐) with Revalidate 1h and Expire 1d, which come from the hours profile. Pattern 1: Public data uses a client that never touches cookies If the data is the same for everyone, it shouldn't go through a cookie-aware client at all. Create a separate client for public reads. With real SDKs, this usually means the plain client created with a public/anon key, not the App Router "server client" helper. // lib/public-client.ts // Stand-in for a cookie-free client (for example a plain SDK client created // with a public/anon key). It never touches cookies() or headers(). export function createPublicClient() { return { async getPublicPosts() { return [ { id: 1, title: 'Hello' }, { id: 2, title: 'World' }, ] }, } } // 1. Public data: cookie-free client, safe to share across all users. async function getPublicPosts() { 'use cache' cacheLife('hours') cacheTag('posts') const posts = await createPublicClient().getPublicPosts() return { posts, generatedAt: new Date().toISOString() } } generatedAt is there to show the caching. With the page served by next start, two requests without cookies and one request with Cookie: locale=fr; session=alice all returned the same generatedAt value. The entry was filled during the build and reused for everyone. Security note: Only do this for data that really is public. If your database enforces row-level security based on the user's session, a cookie-free client sees what an anonymous user sees. That's correct for public data and wrong for anything personal. Pattern 2: Read the cookie outside and pass the value in This is the pattern the error page recommends. The value you pass becomes part of the cache key, so each distinct value gets its own entry. // 2. Data that depends on one small cookie value: read the cookie outside, // pass the value in. The value becomes part of the cache key. async function getGreeting(locale: 'en' | 'fr') { 'use cache' cacheLife('days') return locale === 'fr' ? 'Bonjour' : 'Hello' } async function Greeting() { const raw = (await cookies()).get('locale')?.value const locale = raw === 'fr' ? 'fr' : 'en' return <p id="greeting">{await getGreeting(locale)}</p> } The response without cookies showed Hello. With Cookie: locale=fr it showed Bonjour. Two design choices here matter: - Normalize the value before passing it. raw === 'fr' ? 'fr' : 'en' limits the cache to two entries. If you pass a raw cookie straight through, any client can create a new cache entry by sending a random value. - Prefer low-cardinality values such as locale, theme, plan tier or feature-flag variant. Passing a session token or user ID technically works (one entry per user), but at that point you're building a per-user cache. That's usually better handled by Pattern 3 or 4. Pattern 3: Per-user data stays uncached, inside Suspense Personal data, like "my drafts", can just stay dynamic. Use the cookie-aware client outside any cache scope: // 3. Per-user data: not in a shared cache at all. Rendered at request time // inside a Suspense boundary. async function MyDrafts() { const client = await createServerClient() const drafts = await client.getMyDrafts() return ( <ul id="drafts"> {drafts.map((d) => ( <li key={d.id}>{d.title}</li> ))} </ul> ) } The full page puts it all together: // app/fixed/page.tsx import { Suspense } from 'react' import { cookies } from 'next/headers' import { cacheLife, cacheTag } from 'next/cache' import { createPublicClient } from '@/lib/public-client' import { createServerClient } from '@/lib/server-client' // 1. Public data: cookie-free client, safe to share across all users. async function getPublicPosts() { 'use cache' cacheLife('hours') cacheTag('posts') const posts = await createPublicClient().getPublicPosts() return { posts, generatedAt: new Date().toISOString() } } // 2. Data that depends on one small cookie value: read the cookie outside, // pass the value in. The value becomes part of the cache key. async function getGreeting(locale: 'en' | 'fr') { 'use cache' cacheLife('days') return locale === 'fr' ? 'Bonjour' : 'Hello' } async function Greeting() { const raw = (await cookies()).get('locale')?.value const locale = raw === 'fr' ? 'fr' : 'en' return <p id="greeting">{await getGreeting(locale)}</p> } // 3. Per-user data: not in a shared cache at all. Rendered at request time // inside a Suspense boundary. async function MyDrafts() { const client = await createServerClient() const drafts = await client.getMyDrafts() return ( <ul id="drafts"> {drafts.map((d) => ( <li key={d.id}>{d.title}</li> ))} </ul> ) } export default async function Page() { const { posts, generatedAt } = await getPublicPosts() return ( <main> <p id="generated">{generatedAt}</p> <ul id="posts"> {posts.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> <Suspense fallback={<p>Loading greeting…</p>}> <Greeting /> </Suspense> <Suspense fallback={<p>Loading your drafts…</p>}> <MyDrafts /> </Suspense> </main> ) } The test results: without a session cookie the drafts list was empty. With session=alice it contained Draft for alice. The public posts and their timestamp were identical in both responses. Why the Suspense boundaries matter. With Cache Components, reading cookies() outside <Suspense> blocks prerendering. This page fails the build: // app/nosuspense/page.tsx — fails the build with Cache Components import { cookies } from 'next/headers' export default async function Page() { const session = (await cookies()).get('session')?.value return <p>{session ?? 'anonymous'}</p> } Error: Route "/nosuspense": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`) - [block] Set `export const instant = false` to allow a blocking route Wrapping the cookie-reading component in <Suspense> (as Greeting and MyDrafts are above) is the "[stream]" option. The static shell, which includes the cached public posts, is served right away, and the personal parts stream in. Pattern 4: 'use cache: private' when you can't refactor Sometimes the cookie read is buried in code you can't change easily. 'use cache: private' allows cookies(), headers() and searchParams inside the cached scope: // app/private/page.tsx import { Suspense } from 'react' import { cookies } from 'next/headers' import { cacheLife } from 'next/cache' async function getDashboard() { 'use cache: private' cacheLife({ stale: 60 }) const session = (await cookies()).get('session')?.value ?? 'guest' return { session, at: new Date().toISOString() } } async function Dashboard() { const { session, at } = await getDashboard() return <p id="dash">{session} @ {at}</p> } export default function Page() { return ( <Suspense fallback={<p>Loading…</p>}> <Dashboard /> </Suspense> ) } The important thing is what "private" means. From the docs: "In production, matching calls within one request can reuse the same result, but Next.js does not store it in a server cache across production requests." The client router can keep the output in browser memory for the stale time. The test confirmed this. Under next start, consecutive requests with the same session=alice cookie got a different at timestamp every time. So 'use cache: private' deduplicates within a request and feeds the browser's router cache. It does not make repeated server requests cheaper. If you need that, use Patterns 1–3. Other limits from the docs: - connection() is still not allowed inside it. - You can't configure custom cache handlers for it. - Its stale time feeds the route's client cache. It must be at least 30 seconds for per-link prefetching and at least 5 minutes to be included in the route's App Shell. Choosing a pattern | Your data | Pattern | | --- | --- | | Same for everyone | 1: cookie-free client inside 'use cache' | | Varies by a small, known set of values from a cookie/header | 2: read outside, normalize, pass as argument | | Specific to the signed-in user | 3: uncached, in a component wrapped in <Suspense> | | Per-user, and the cookie read can't be moved | 4: 'use cache: private', but no cross-request server cache | Common mistakes and troubleshooting - "My function doesn't call cookies()." Something it calls does. Check the stack trace. The first frame is the actual cookies() call, and the next frames show the path back to your cached function. - The build passes, production throws. On dynamically rendered routes, the error shows up when the route runs. Exercise the route under next start before deploying. - Passing the whole cookie store or headers() object. Arguments must be serializable, and class instances aren't. Extract the plain values you need. - Passing unbounded values. Each distinct argument creates a cache entry. Validate and normalize anything that comes from the client. - Using React.cache to smuggle values in. React.cache is isolated inside 'use cache' boundaries. Values set outside aren't visible inside. Use arguments. - Module-level promise caches. The error page notes that a cached function can stall if it awaits a promise stored outside the function (for example, in a module-scoped Map). 'use cache' already deduplicates calls with the same arguments, so remove the extra layer. - Draft Mode. You can read draftMode().isEnabled inside 'use cache', but cookies() and headers() are still off-limits, even in draft mode. Limitations and alternatives - With the default in-memory cache handler on serverless platforms, runtime cache entries "typically don't persist across requests", although build-time caching works normally. Self-hosted servers keep entries in memory. If you need a shared runtime cache, look at 'use cache: remote', which uses a platform-provided handler and has network and cost trade-offs. - Cache entries don't survive a new deploy, because the build ID (or deploymentId) is part of the key. - Cache Components needs the Node.js runtime and doesn't support the runtime route segment config. Remove export const runtime = ... from routes when you adopt it. - Don't wait for plain 'use cache' to start accepting cookies()/headers(). A reply in discussion #82873 (August 2025) says "There's no plan to allow use cache to access cookies/headers" and suggests using 'use cache' as much as possible and the private variant only rarely. The current docs take the same position. Conclusion The error is Next.js protecting you from serving one user's data to another. Most of the time the fix is structural. Keep public reads on a cookie-free client inside 'use cache'. Read cookies at the edge of your component tree, normalize the values, and pass them in as arguments. Keep personal data uncached behind <Suspense>. Use 'use cache: private' only when you can't move the cookie read, and remember that it doesn't give you a cross-request server cache. Next step: search your codebase for helpers that call cookies() or headers() and check whether any of them run below a 'use cache' boundary. Then run the affected routes under next start, not just next build. References - use cache directive: nextjs.org - Error: Cannot access request data in use cache: nextjs.org - use cache: private: nextjs.org - use cache: remote: nextjs.org - cacheComponents config: nextjs.org - Caching guide: nextjs.org - Blocking prerender error: nextjs.org - Next.js discussion #82873, "Allow cookies() or headers() in use cache": next.js#82873 - Next.js 16.4 release post: nextjs.org
Prerendering a page per device, and the cache key that has to agree --- title: "Prerendering a page per device, and the cache key that has to agree" published: true description: "A prerendered page in the Next.js App Router is one file per URL, and device specific markup needs two. Generating the extra routes keeps the public URL intact. Making your device split agree with the cache in front of it is the part nobody warns you about." tags: nextjs, webdev, caching, performance canonical_url: yhryshchanka.dev ai_disclosure_level: some_ai --- A marketing page with no per visitor content should be built once and handed out as a file, which is what prerendering means here: the build writes the HTML, and the server answers with it instead of rendering again. The page I had to do this to was not quite static. Its footer is an accordion on a phone and a grid on a laptop, and a few sections below it differ the same way. Two rendered versions of one document have to exist at the same time. The browser can make that choice by itself, which is the first thing to rule out. A CSS media query renders both versions and hides one, so the same text sits in the document twice and every visitor downloads the one they will never see. JavaScript picks after the page has loaded, so the first paint is a guess and the reader watches it change. Either way the page ships markup the device in front of it will not use. Choosing at build time means one version per file, and the HTML a crawler reads is the HTML a phone gets. Everything below happens inside the application, in the Next.js App Router, on version 15. The same split can live a layer above it instead. Inside the application, that means two files and one URL. A shared cache in front of it keys on that URL and nothing else, so on its own it cannot tell the two files apart. What it can do instead is split its own storage by device, and making my split agree with its split decided the shape of everything else. The whole thing, before the explanation Two prerendered files per page, one public URL, and a rewrite that picks between them before the request reaches a route. Everything below this section is the reasoning. This is the code, and it runs. A page opts in with one export, and refuses to render at its own path, where no device arrives: app/terms-of-use/page.tsx import Layout from '@/app/_layout/Layout' import type { DeviceSegment } from '@/lib/device' import { notFound } from 'next/navigation' export const isDeviceBasedSSG = true export const metadata = { title: 'Terms of use' } export default function TermsPage({ device }: { device?: DeviceSegment }) { if (!device) notFound() return <Layout device={device}>Terms of use</Layout> } A script turns that marker into one route per device, plus the list of paths that got one: scripts/generate-device-routes.mjs import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' const DEVICES = ['desktop', 'mobile'] const SOURCE_DIR = 'app' const OUT_DIR = join('app', '(generated)') const PATHS_FILE = 'lib/cacheable-paths.generated.ts' const MARKER = /export\s+const\s+isDeviceBasedSSG\s*=\s*true/ // A folder named `(group)` adds no segment to the URL. A folder named `_private` is out of // routing altogether, so a page under one is not a route and must not get a wrapper. const isGroup = (name) => name.startsWith('(') const isPrivate = (name) => name.startsWith('_') function findMarkedPages(dir, routePath = '') { const entries = readdirSync(dir, { withFileTypes: true }) const here = entries.some((entry) => entry.isFile() && entry.name === 'page.tsx') && MARKER.test(readFileSync(join(dir, 'page.tsx'), 'utf8')) ? [{ routePath: routePath || '/', dir }] : [] return [ ...here, ...entries .filter( (entry) => entry.isDirectory() && !isPrivate(entry.name) && join(dir, entry.name) !== OUT_DIR, ) .flatMap((entry) => findMarkedPages( join(dir, entry.name), isGroup(entry.name) ? routePath : `${routePath}/${entry.name}`, ), ), ] } // The import path goes into a string literal and never into a comment: a directory // name may carry a line terminator, which ends a // comment and drops the rest into // code the build compiles. const wrapper = (importPath, device, metadataExport) => `// Generated. Do not edit. import Page${metadataExport ? `, { ${metadataExport} }` : ''} from ${JSON.stringify(importPath)} export const dynamic = 'error' ${metadataExport ? `\nexport { ${metadataExport} }\n` : ''} export default function GeneratedDeviceRoute() { return <Page device="${device}" /> } ` const marked = findMarkedPages(SOURCE_DIR) for (const { routePath, dir } of marked) { // A wrapper is a route at a fixed path, so it cannot carry a parameter, and it sits // directly under the app root, so it cannot inherit a layout from the source's folder. if (routePath.includes('[')) { throw new Error(`${dir}: a device route cannot hold a dynamic segment (${routePath})`) } const ancestors = routePath.split('/').filter(Boolean).slice(0, -1) for (let i = 0; i < ancestors.length; i += 1) { const layout = join(SOURCE_DIR, ...ancestors.slice(0, i + 1), 'layout.tsx') try { readFileSync(layout) throw new Error(`${dir}: ${layout} would not apply to the generated route`) } catch (error) { if (error.code !== 'ENOENT') throw error } } } rmSync(OUT_DIR, { recursive: true, force: true }) for (const { routePath, dir } of marked) { const source = readFileSync(join(dir, 'page.tsx'), 'utf8') const metadataExport = /export\s+(async\s+)?function\s+generateMetadata/.test(source) ? 'generateMetadata' : /export\s+const\s+metadata\b/.test(source) ? 'metadata' : '' for (const device of DEVICES) { const outFile = join(OUT_DIR, device, routePath, 'page.tsx') const importPath = relative(dirname(outFile), join(dir, 'page')).split('\\').join('/') mkdirSync(dirname(outFile), { recursive: true }) writeFileSync(outFile, wrapper(importPath, device, metadataExport)) } } writeFileSync( PATHS_FILE, `// Generated. Do not edit.\nconst CACHEABLE_PATHS = ${JSON.stringify( marked.map((page) => page.routePath), )} as const\n\nexport default CACHEABLE_PATHS\n`, ) console.log(`generated ${marked.length * DEVICES.length} routes for ${marked.length} pages`) Run it before anything that reads the routes, and keep what it writes out of the repository: package.json { "scripts": { "generate:routes": "node scripts/generate-device-routes.mjs", "predev": "npm run generate:routes", "prebuild": "npm run generate:routes", "pretypecheck": "npm run generate:routes", "pretest": "npm run generate:routes" } } .gitignore # Written by scripts/generate-device-routes.mjs. app/(generated)/ lib/cacheable-paths.generated.ts Middleware rewrites the public path into the generated route, so the visitor's URL and the canonical tag never carry a device: middleware.ts import CACHEABLE_PATHS from '@/lib/cacheable-paths.generated' import { deviceFromHeaders, isInternalDevicePath } from '@/lib/device' import { NextResponse, type NextRequest } from 'next/server' export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // Nothing links to the internal form, so a request carrying one was typed or crawled. if (isInternalDevicePath(pathname)) { return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 }) } if (!(CACHEABLE_PATHS as readonly string[]).includes(pathname)) return NextResponse.next() return NextResponse.rewrite( new URL(`/${deviceFromHeaders(request.headers)}${pathname}`, request.url), ) } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], } One device read that every page shares, and the guard the middleware just used: lib/device.ts import { userAgent } from 'next/server' export const DEVICE_SEGMENTS = ['desktop', 'mobile'] as const export type DeviceSegment = (typeof DEVICE_SEGMENTS)[number] const LAYOUT_BY_CDN_TYPE: Record<string, DeviceSegment> = { mobile: 'mobile', tablet: 'mobile', desktop: 'desktop', } export function deviceFromHeaders(headers: Headers): DeviceSegment { const cdnType = headers.get('cf-device-type') ?? '' // Own keys only: outside a cache rule this header is whatever the client sent, and // `constructor` or `__proto__` would otherwise resolve to a member of the prototype. if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType] return userAgent({ headers }).device.type === 'mobile' ? 'mobile' : 'desktop' } export function isInternalDevicePath(pathname: string): boolean { const [, first] = pathname.split('/') return (DEVICE_SEGMENTS as readonly string[]).includes(first) } And the response headers, because a prerendered route otherwise advertises a year of shared caching to whatever sits in front of it: next.config.mjs import CACHEABLE_PATHS from './lib/cacheable-paths.generated.ts' /** @type {import('next').NextConfig} */ export default { async headers() { return CACHEABLE_PATHS.flatMap((path) => [ { source: path, has: [{ type: 'header', key: 'rsc' }], headers: [{ key: 'Cache-Control', value: 'private, no-store' }], }, { source: path, missing: [ { type: 'header', key: 'rsc' }, { type: 'query', key: '_rsc' }, ], headers: [{ key: 'Cache-Control', value: 'public, max-age=0, s-maxage=3600' }], }, ]) }, } That is the implementation, on Next 15 with the App Router. Next 16 renames the file to proxy.ts and the function to proxy, which I have not run. The rest of this article is why each of those decisions is the one that survived, and the two failures none of the code shows on its face: a build that stays green while nothing is prerendered, and a cache that hands one visitor's layout to everybody else. The cache key is the path, and nothing from the request Next keeps the HTML it prerendered in what it calls the full route cache. The key for that cache is the resolved route path. In 15.5 it is one line of next/dist/server/base-server.js: let resolvedUrlPathname = getRequestMeta(req, 'rewroteURL') || urlPathname and getFilePath in the file system cache turns that same string into a path on disk. Nothing else from the request reaches it. Next does send a Vary header for whatever caches the response downstream, but its own route cache does not consult one. The query string does not help either. I measured that rather than reasoning about it: with the page not reading searchParams, ?device=m, ?device=d and no query at all came back byte for byte identical, all three hashing to 4005cda574c9, one cache entry answering every one of them. A page that reads searchParams is not prerendered at all, and whether that is a build failure or a silent fallback to rendering on demand is the subject of a later section. Rewrites declared in next.config do not create routes either, they point at routes that already exist, so they add no dimension to the key. I pointed one at a prerendered route from beforeFiles and another from afterFiles, and both source URLs came back identical to the route itself, all three hashing to d0fa5dfdb35c. One entry, three ways in. Two variants therefore mean two paths, and the only varying input a prerendered render can see is a route parameter. Where the shape came from I did not invent this. The shape comes from a comment by a Next collaborator in a discussion about serving a light and a dark theme out of one cache: > Prerender per known theme > > generateStaticParams returns the theme list. Pages live under [theme]/. A > proxy rewrites /foo → /<cookie>/foo server-side, and redirects direct > visits to /light or /dark back to the unprefixed path so themed URLs > aren't shareable. > > Each route ends up with one cache slot per theme. > > HTML arrives themed. No FOUC, no JS required. > > Server can branch on theme, that is, RTL, different components, distinct meta. Read it with devices in place of themes and it says four things. Make the variant a route parameter. Move the pages that want variants under that parameter's folder. Let generateStaticParams prerender one copy per value. Have middleware, the function that runs before a request reaches a route, rewrite the public URL into the variant so the visitor never sees it. He names two costs: > Switching theme is a server round trip, an actual navigation. > > Proxy on every request, between you and your CDN. Neither reaches this case. Nobody switches devices, so there is no switch to pay for. And middleware was already running on every request here, for locale routing and for putting visitors into experiments, so its cost was on the bill before this feature existed. Two parts of that I did not want. A dynamic segment in the first position costs more than the URLs it agrees to match. Once the first segment is a parameter, every first level path is a valid route, and the framework's own not found stops firing for a wrong one, because from its side nothing is wrong. Something inside the tree has to decide which values are real and call notFound() for the rest, which makes that check the only place the legal values are written down. A reader who wants to know what the site accepts can no longer look at the folders, they have to open the code. A whitelist does not rescue it either, because Next has no fall through: once a route matches, notFound() renders the not found boundary rather than handing the request to the next candidate route. The shared parent folder fails more quietly. Every page that wants a variant has to move under it, and pages that cannot have one stay where they are. Say articles/ holds two pages, one that can be cached and one that reads a cookie and therefore cannot. The first moves under [theme]/ and the second does not, so articles/ now exists in two places at once. Add a page, and remember the second tree. Rename a folder, and remember it again. The build is green either way, and the mistake shows up as a page that quietly stopped being prerendered. So the parameter went away, and two literal folders replaced it, desktop/ and mobile/, each holding a real page file. A folder matches its own name and nothing else, so an unknown value now matches no route at all. That is what generateStaticParams and dynamicParams were there to arrange, and what the guard was there to catch when they failed, so all of it goes. What literal folders do not remove is the duplicated tree. They move it: desktop/ and mobile/ each hold a copy of every marked page. What changes is who keeps the copies in step. Two trees a person edits is the objection above. Two trees a script writes is a build step, because nobody edits them and nobody reviews them. That is the whole argument for generating them. What the generator writes The whole opt-in is one export, at the page's normal path: app/terms-of-use/page.tsx export const isDeviceBasedSSG = true The script writes two things from it: a route per device, and the list of paths that got one. The list is a module rather than a directory scan, because the same code has to work wherever the rewrite runs, and because deriving it from the marker is what stops it drifting away from the folders it names. Each generated route carries no logic of its own: app/(generated)/desktop/terms-of-use/page.tsx // Generated. Do not edit. import Page, { metadata } from '../../../terms-of-use/page' export const dynamic = 'error' export { metadata } export default function GeneratedDeviceRoute() { return <Page device="desktop" /> } The wrapper takes no props of its own on purpose. A page file is type checked against the props Next passes a route, and a wrapper that declares anything else fails that check. Metadata is re-exported, and a wrapper that forgets it leaves the page with no title and no description, which is why the script reads the source for a metadata export and emits the re-export only when it finds one. The line that matters more than either of those is dynamic = 'error'. The one line that turns a silent failure into a build failure Nothing in a prerendered route's tree may read cookies(), headers() or searchParams. That includes the layout and the not found boundary, which sit in every route's tree. One such read anywhere puts the whole route back on per request rendering, and the build stays green while it happens. The not found page is the one that catches people out, and it is also the one page whose device genuinely cannot be known, so it takes a fixed value rather than reading for one. dynamic = 'error' is what turns that class of mistake into a build failure, and the failure names the API: `Route /desktop/terms-of-use with dynamic = "error" couldn't be rendered statically because it used headers`. The variant you serve has to be the variant the cache stored Everything above gets you two prerendered files and a rewrite that picks one. Put a shared cache in front and a new failure appears, because the cache stores one body per bucket of its own choosing, and it chose that bucket with its own classifier. Cloudflare, for one, can split a cache rule by device type and then sends the origin a CF-Device-Type header carrying the bucket it used. If middleware picks the branch with a User Agent parser while the edge keys on the CDN's answer, then for every user agent where the two disagree, whoever arrives first decides what the entire bucket serves to everyone behind them. They disagree on real traffic, and not on anything exotic. A native app's networking stack and a reading device asked to behave like a desktop both landed in one bucket at the edge and another in my parser. The particular agents will age, so take the category instead of the list: anything whose user agent does not describe the screen it draws on. Whatever you parse with has to collapse to the buckets the cache actually holds. Mine had a third answer, unknown, for anything it did not recognise, and under two buckets a third answer is not a third variant, it is a coin toss about which of the two documents a visitor gets. The read above has no third branch for that reason. Every disagreement I found became a test case. The fix is one server side read, used by every page, taking the CDN's answer first and the user agent second: lib/device.ts export function deviceFromHeaders(headers: Headers): DeviceSegment { const cdnType = headers.get('cf-device-type') ?? '' // Own keys only: outside a cache rule this header is whatever the client sent, and // `constructor` or `__proto__` would otherwise resolve to a member of the prototype. if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType] return parseUserAgent(headers.get('user-agent') ?? '').isMobile ? 'mobile' : 'desktop' } Tablets map to the mobile layout, which means three buckets hold two documents and the tablet copy duplicates the mobile one. That is the price of letting the cache own the classification. The internal paths have to be unreachable Nothing links to /desktop/terms-of-use, so a request carrying one was typed or crawled. It is also a real route, and I checked what happens without the guard: it answers 200 and serves the document. The same page at a second URL, with whatever search weight the real one has split between them. lib/device.ts export function isInternalDevicePath(pathname: string): boolean { const [, first] = pathname.split('/') return (DEVICE_SEGMENTS as readonly string[]).includes(first) } The rewrite goes to /_not-found with an explicit status: 404. Without the status it is a soft 404: the not found page at 200, which is the duplicate the guard exists to prevent, wearing a different hat. What I would keep Keep the variant as a path segment, because nothing else reaches the key. Keep literal folders rather than an enumerated dynamic segment, because a folder cannot match a value it is not named after. Keep one device classifier for the whole application, and if a shared cache sits in front, make it the cache's classifier. Keep dynamic = 'error' on every variant route. Keep a check that reads the prerender manifest rather than the route table, and a second one that requests the URLs nobody should request and expects a 404. Keep a check on the bytes themselves, which asks a running build for the same page twice and compares what comes back: same bytes inside one device class, different bytes between classes, and no change when the request carries cookies. Take the cookie names from the application's own constants rather than retyping them, or the check sends a cookie nothing reads and quietly passes on two requests that both carried nothing. Keep the generator, and know what you are buying with it. Two hand written wrappers of four lines each do the same job today, for two cacheable pages. The day somebody marks a third page, the wrappers and the path list have to follow, and a person who forgets gets no error, just a page quietly rendering per request again. A marker that generates both of them cannot be forgotten. That is the only thing the script buys. The price is a prebuild step in front of every command a developer runs, stack traces that point at files which are not in the repository, an error that names a wrapper when the mistake is in the source page, a tree that can be stale, and a script that writes paths into code the build then compiles. For two pages it is a bad trade. For a site where anybody can mark a page, I would make it again. The generated tree is in .gitignore, and that is the call in all of this I am least settled about. It keeps the repository honest about what a person wrote. It also means nothing in the repository shows a reader what actually gets built, and a stack trace can point at a file they cannot open. The code here is a rewrite of what shipped, generic enough to drop into any project. To be sure the rewrite is more than a sketch I built a clean Next 15.5 app out of exactly these files and drove it, and the build output, the response headers and the hashes above come from that app. What a local app cannot show is how two classifiers disagree on real traffic, and that part comes from the application the design shipped in.
create-next-app でPages Routerにする選択肢を無くすらしい、ほかフレームワークを使えってことなのかな next.js#100850
Next.js 16 で images.domains 廃止 — 3 アプリを remotePatterns に書き直す Cosoado Lab Blog 同時掲載予定: cosoado-lab.com Next.js 16 に上げた月曜の朝、build ログに DeprecationWarning: The "images.domains" configuration is deprecated が並んだ。v14 のドキュメントには deprecated と書いてあったが、runtime warning が出ていなかったので 2 年近く放置し続けた報いだ。Next.js 14 のリリースは 2024 年だから、計算したらそうなる。なんで今まで気づかなかったかは聞かないでほしい。SparMate・NetaPair・BoardLink の next.config.ts を一気に直した。ついでに images.localPatterns という別の罠も踏んだので両方まとめておく。 TL;DR images.domains → Next.js 16 で deprecation warning。移行先は images.remotePatterns(プロトコル・パスまで絞れる) query string 付きローカル URL <Image src="/api/photo?id=1" /> は Next.js 16 からエラー。images.localPatterns で許可が必要 参照: PR #84625 / PR #84406 images.domains が deprecated になった理由 images.domains の問題は許可の粒度が粗すぎることだ。ホスト名だけを書くと全パス・HTTP と HTTPS の両方が許可される。「このパスの HTTPS だけ許可したい」という絞り方ができない。 ?url=lh3.googleusercontent.com を渡せばそのホストからなら何でも画像最適化エンドポイントを通過してしまう。Next.js 12.3 で追加された remotePatterns はプロトコル・ホスト名・パスをそれぞれ絞れる。domains はその時点で役目を終えていた。 remotePatterns への移行 // before const config: NextConfig = { images: { domains: ['avatars.githubusercontent.com', 'lh3.googleusercontent.com'], }, } // after import type { NextConfig } from 'next' const config: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'avatars.githubusercontent.com', pathname: '/u/', }, { protocol: 'https', hostname: 'lh3.googleusercontent.com', pathname: '/a/', }, ], }, } export default config protocol: 'https' で HTTP リクエストをはじき、pathname でパスを絞る。/** は任意のサブパスにマッチするワイルドカードだ。import type { NextConfig } を書いておくと remotePatterns のプロパティが TypeScript で補完される。 1 行書き換えでは済まないが、使っているドメインを書き出すだけなので 3 アプリ分でも 15 分かからない。 localPatterns の新要件(Next.js 16 breaking change) images.domains の警告に気を取られていたら、もう 1 つ踏んだ。 Next.js 16 から、<Image> に query string 付きのローカル URL を渡すには images.localPatterns での許可が必要(PR #84406)。 // Next.js 16 から runtime error になる <Image src={/api/user-photo?userId=${userId}} width={64} height={64} alt="avatar" /> // Error: ... is using a query string which is not configured in images.localPatterns. デフォルトの localPatterns は { pathname: '/**', search: '' } — search が空文字列、つまり「query なし」しか通さない。修正: const config: NextConfig = { images: { localPatterns: [ { pathname: '/api/user-photo', // search を省略すると任意の query を許可 }, ], }, } opengraph-image.tsx + ImageResponse の OG 画像生成パターン(前の記事)は <Image> を経由しないので影響なし。admin 画面で <Image src="/api/og?preview=true" /> のように query 付きローカル URL を渡している場合は対象になる。 これは一番やった失敗です remotePatterns に移行するとき、最初に書いたのがこれだった: remotePatterns: [ { protocol: 'https', hostname: '**', // ← 全ホスト許可。domains と何も変わらない }, ], 「warning が消えた、動いた、commit」と半自動的にやった。セルフレビューで気づいたのが commit の 10 分後で、push 前だったのは不幸中の幸いだ。hostname: '**' は全ドメインを許可するワイルドカードで、images.domains: ['*'] でホワイトリスト全開放するのと同じことになる。remotePatterns の意味は「このホストのこのパスだけ通す」であって、wildcard 全開放は趣旨を外れている。 まとめ images.domains → images.remotePatterns に移行。protocol + pathname まで絞ることでセキュリティが上がる query string 付きローカル <Image> は images.localPatterns での許可が必要(Next.js 16 breaking change) hostname: '**' で移行した気になるのが一番よくないパターン 1 アプリ 10 分の作業。warning を「後でやる」で積んでいくとバージョンアップのたびに地雷が増える。 この記事の設定を実際に使っているプロダクト: SparMate — スポーツマッチングアプリ NetaPair — ネタ系交流マッチングアプリ BoardLink — ボードゲームマッチングアプリ Cosoado Lab — 個人開発ラボ
next.js: The Hottest JavaScript Repo on GitHub Right Now #Shorts In this video, we break down vercel/next.js - one of the hottest open-source projects on GitHub right now. The React Framework. It has already earned 143,096 stars on GitHub and is written primarily in JavaScript. We look at what it does, why developers are paying attention to it right now, and who should try it. 📂 Repo: vercel/next.js 🌐 Stars: 143,096 | Language: JavaScript | Forks: 35,275 🔔 Subscribe for daily picks of the hottest GitHub repos: / @reporadar #github #opensource #programming keywords: coding, developer tools, github, opensource, programming, software, tech #Shorts
eslint-plugin-reactから@eslint-react/eslint-pluginに移行 Reactのlintルールをeslint-plugin-reactから@eslint-react/eslint-pluginに移行した。 移行の理由 eslint-plugin-reactがESLint 10へのブロッカーになっていたため。ESLint 10では非推奨のAPIの削除があったが、eslint-plugin-reactでは未対応であった。ESLint 10への対応PR自体は7月末出ていたが、記事執筆時点(2026/10/24)では未マージである。 PR: eslint-plugin-react#4022 Next.jsのプロジェクトでは、プロジェクト側で互換レイヤーを作成することによって使用を継続していた。ただ、AIである程度簡単にできるとはいえESLintの変更に応じてパッチが必要になるのも面倒なので移行した。 Next.jsの変更: github.com/vercel/next.js/commit/907721a66e8b4174… 移行先 @eslint-react/eslint-pluginのリポジトリ: Rel1cx/eslint-react ルールについては全てのeslint-plugin-reactのルールをカバーしているわけではないので、移行の際には注意が必要である。 ルールの対応表: eslint-react.xyz 個人のリポジトリであるため、会社のルールによっては使えない可能性もある。ESLint Communityへの招待も来ているが、実際に今後移管されるかは未定である。 招待: eslint-react#1950
next.js: Why 0 developers just starred next.js ⚡ Tech breakdowns, AI news, and future trends. Hit subscribe to stay ahead. next.js is With 0 stars on GitHub, it's one of the fastest-growing tools in its category. 🔗 GitHub: vercel/next.js 🌐 Website: letsmakeourworld.vercel.app 🎁 next.js Starter Kit — setup guide + templates 💬 Comment "FUTURE58" below and we'll send you the link to try it yourself! 🔔 Subscribe for weekly deep dives on trending open source tools. 🚀 Build with HyperAutomationLabs: Website: letsmakeourworld.vercel.app Source/demo repo: vercel/next.js 🔔 Subscribe for more tech content from HyperAutomationLabs. 💬 Comment "BUILD78" below to get next.js Starter Kit — setup guide + templates or tell us what use case you want us to build next. 📌 Timestamps: 0:00 Introduction #HyperAutomationLabs #tech #AI #Automation #BuildInPublic --- ⚠️ ARCHIVAL NOTICE & AGENT PRODUCTION DISCLAIMER: This video was researched, written, voiced, and edited autonomously by an AI documentary agent (ProjectNexus Autonomous Media Engine). Content is synthesized from public domain historical archives, declassified records, investigative news reports, and open cold case dossiers for educational, documentary, and historical preservation purposes. Living individuals mentioned are presumed innocent unless proven guilty in a court of law.
next.js: The Hottest JavaScript Repo on GitHub Right Now In this video, we break down vercel/next.js - one of the hottest open-source projects on GitHub right now. The React Framework. It has already earned 143,096 stars on GitHub and is written primarily in JavaScript. We look at what it does, why developers are paying attention to it right now, and who should try it. 📂 Repo: vercel/next.js 🌐 Stars: 143,096 | Language: JavaScript | Forks: 35,275 🔔 Subscribe for daily picks of the hottest GitHub repos: / @reporadar #github #opensource #programming keywords: coding, developer tools, github, opensource, programming, software, tech