Published · 5 min read
Next.js 16 Cache Components: what broke when I turned it on
I built this site with cacheComponents enabled in Next.js 16. These are the errors I hit, what each one means, and the fix that made it build.
I built this site on Next.js 16 with cacheComponents: true. The model is good: every page is static by default, and anything dynamic has to say so. Getting there meant fixing a handful of errors that older App Router habits walk straight into.
Here they are, in the order I hit them.
1. new Date() fails the build
A footer with the current year is enough:
export function Footer() {
return <p>© {new Date().getFullYear()}</p>;
}With Cache Components, Next.js prerenders everything it can. The current time is not a stable value, so it stops and tells you it "encountered the unstable value" while prerendering. Date.now() and Math.random() do the same.
You have two honest choices, and they mean different things.
// "Compute this once and reuse it"
export async function Footer() {
"use cache";
return <p>© {new Date().getFullYear()}</p>;
}// "Compute this on every request"
import { connection } from "next/server";
async function loadStats() {
await connection();
const since = new Date(Date.now() - 30 * 86400_000);
// ...
}The footer year belongs in the cache. A "last 30 days" query in an admin page belongs to the request.
2. dynamic and dynamicParams are gone
I reached for the old escape hatch out of habit:
export const dynamicParams = false;The build refuses it and says the route segment config is not compatible with cacheComponents. Same for export const dynamic = "force-static".
There is no replacement line to paste. The replacement is the model itself: use "use cache" where something can be reused, and read request data or call connection() where it cannot.
3. generateStaticParams cannot return an empty list
My blog reads posts from a database. On a fresh database there are zero posts, and the build failed on the post route for having no params.
I return one placeholder and let the page turn it into a 404:
export async function generateStaticParams() {
const params = (await getPosts("en")).map((p) => ({ slug: p.slug }));
return params.length ? params : [{ slug: "__none__" }];
}
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
const post = await getPost(slug, "en");
if (!post) notFound();
// ...
}It is not pretty. It builds on an empty database, which matters the first time you deploy.
4. await params needs a Suspense boundary
Reading params, searchParams, cookies(), or headers() makes that part of the tree dynamic. If nothing above it is wrapped in <Suspense>, the whole route blocks, and Next.js reports uncached data accessed outside of <Suspense>.
The fix is to split the page in two:
async function Login({ searchParams }: PageProps<"/admin/login">) {
const { error } = await searchParams;
return <LoginForm error={error} />;
}
export default function LoginPage(props: PageProps<"/admin/login">) {
return (
<Suspense>
<Login {...props} />
</Suspense>
);
}The outer component is the static shell. The inner one streams in.
5. usePathname in the root layout does it too
I had a small client component in the root layout that sends a page view on every route change. It calls usePathname().
On a dynamic route the pathname is not known at build time. So that one hook pulled every page out of the static shell. Wrapping the component fixed it:
<Suspense fallback={null}>
<PageView />
</Suspense>6. Database content that should still be static
Blog posts live in Postgres, and I still want them prerendered. A cached function with a tag does that:
import { cacheLife, cacheTag } from "next/cache";
async function dbPosts(locale: Locale) {
"use cache";
cacheTag("posts");
cacheLife("days");
return db.select().from(posts).where(/* published, this locale */);
}When I publish from the admin, the server action calls revalidateTag("posts", "max"). The pages rebuild and nothing else does.
I use the same pattern for the sitemap. Before that, it queried the database on every crawler request and stamped every URL with the current time. Now it is built once per deploy, refreshed on publish, and the last-modified dates only change when the content does.
One warning from experience. A cached function that hits the database runs at build time. A slow database then becomes a failed deploy. Mine stalled once behind a connection pooler and the build died after two minutes. I now put a 15 second deadline and a retry on a fresh connection around those queries.
What I'd do again
Turn Cache Components on at the start of a project, not at the end. Every error above is the framework pointing at a place where I had not decided whether something was static or per request. Deciding early is cheap.
The short version of the rules I now follow:
| I want | I write |
|---|---|
| Reuse this result | "use cache", plus cacheTag if it can change |
| Fresh on every request | await connection(), or read cookies or params |
| A page that uses request data | Wrap the reading part in <Suspense> |
| Refresh after an edit | revalidateTag(tag, "max") in the server action |
This site is the result. If you want something built this way, send me a message.