seifashraf.tech
01work02notes03blog04contact
CV
open to work
All writing

Published 8 October 2026· 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.

Written by Seif Ashrafnext.jsarchitecturedeploy

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>

Anything in a shared layout that reads the URL, cookies, or the session is a candidate for this. Check the layout first when a whole section stops being static.

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 wantI write
Reuse this result"use cache", plus cacheTag if it can change
Fresh on every requestawait connection(), or read cookies or params
A page that uses request dataWrap the reading part in <Suspense>
Refresh after an editrevalidateTag(tag, "max") in the server action

This site is the result. If you want something built this way, send me a message.

Share Email

Building something like this?

I reply within 24 hours.

Tell me about your project
PreviousStripe webhooks in NestJS: raw body, signature, safe retriesNextArabic is a layout problem, not a translation problem

© 2026 Seif Ashraf · Cairo · +20 100 700 4828

GitHubLinkedInInstagramFacebookblog· no cookies
Let's talk