Loading…
Loading…
cache() and unstable_cache() sound like two names for one thing. They solve different problems, and mixing them up produces a genuinely irritating TypeError.

The article page worked perfectly. Loading it a second time threw createdAt.toISOString is not a function.
Nothing changed between the two loads. Except one thing: the second one came from cache.
Next has two caching mechanisms that are easy to confuse, because their names are similar and both wrap the same kind of data-reading function.
React's cache() deduplicates identical calls within a single request. It is what stops the article page from querying the database twice: generateMetadata needs the title to build meta tags, the page body needs the same post to render, and both call one function with one slug. Without it that is two round trips for one page.
unstable_cache() holds a result across requests, until something deliberately clears it. That is the layer which means the post never asks the database again after the first visit.
They nest, with cache() on the outside:
const getPostRaw = unstable_cache(
async (slug) => { /* query Supabase */ },
["published-post-by-slug"],
{ tags: ["posts"], revalidate: false }
);
export const getPost = cache(async (slug) => {
const raw = await getPostRaw(slug);
return raw;
});
revalidate: false means hold it indefinitely. The data only goes stale when a write path calls revalidateTag("posts").
Here is the source of that opening error. unstable_cache serializes its result to JSON in order to store it. JSON has no date type. A Date goes in and a string comes out.
On the first run the function returns the object you just built, so createdAt is a real Date and .toISOString() is fine. On the second it comes back through the JSON round trip, and createdAt is now a string. TypeScript cannot catch this: the declared return type says Date, and at the type level that is accurate.
The fix is to make the serialized layer hold only JSON-safe values, then rebuild the richer type in the outer wrapper:
const getPostRaw = unstable_cache(
async (slug) => ({ ...row, createdAt: row.created_at ?? null }), // ISO string
["published-post-by-slug"],
{ tags: ["posts"], revalidate: false }
);
export const getPost = cache(async (slug) => {
const raw = await getPostRaw(slug);
if (!raw) return null;
return { ...raw, createdAt: raw.createdAt ? new Date(raw.createdAt) : null };
});
The serialization boundary now sits somewhere you can see it, and every caller outside still receives a Date.
The same trap is waiting for anything else JSON cannot carry. A Map comes back as an empty object, a Set as an empty object, undefined values vanish from the payload entirely, and a BigInt throws while being written rather than while being read. If a cached function returns anything richer than strings, numbers, booleans, arrays and plain objects, that boundary deserves an explicit conversion instead of a hopeful type annotation.
The second trap costs more, because it does not crash. It just serves old data.
revalidatePath("/blog") drops the rendered HTML for that route. It does not touch a tagged unstable_cache entry. The page rebuilds, calls the data function again, and that function happily hands back the same cached value. You get a freshly rendered page full of stale content.
So every write path has to call both, and revalidateTag is the line people forget:
function revalidateBlogPaths(slug) {
locales.forEach((locale) => {
revalidatePath(`/${locale}/blog`);
if (slug) revalidatePath(`/${locale}/blog/${slug}`);
});
revalidateTag("posts");
}
The comments on an article are wrapped in cache() only, never unstable_cache(). That is a deliberate choice.
Comment moderation invalidates through revalidatePath, and as above that command cannot reach a tagged entry. Adding a cross-request layer here would create the situation where you approve a comment, the page rebuilds, and the comment still does not appear. Keeping that read live is the cheapest way to keep it honest.
Condensed to one line: use cache() so one request never asks twice, use unstable_cache() so the next request never asks at all, and if you are not confident you will remember to clear the tag on every write path, do not add the second layer.
No comments yet — be the first!