Firestore bills per document read. Next.js renders on the server. Put those two facts together carelessly and you get a site where every visitor to the blog index costs you one read per post, every time, forever — and a homepage that queries four collections on every request to render content that changes once a week.
This site is Firebase-backed, content-managed through an admin panel, and serves almost every page from cache. An edit in the admin goes live immediately — no redeploy, no waiting for a revalidation window. Here is the whole pattern, which is about sixty lines.
The two demands are in tension
A CMS-backed site has to satisfy two things that pull in opposite directions.
Almost never fetch. Content changes rarely. A blog post is written once and read thousands of times. Hitting Firestore for each of those reads is money spent and latency added for data that has not changed.
Be instantly fresh. When the author fixes a typo and hits save, refreshing the page must show the fix. "It'll appear within the hour" is not acceptable — the author assumes the save failed and saves again.
Time-based revalidation alone cannot do both. Short window, and you are paying for reads on content that did not change. Long window, and edits are invisible. Every value you pick is wrong in one direction.
The resolution is to stop treating freshness as a function of time.
Cache by tag, invalidate by event
Next's unstable_cache caches a function's result across requests and lets you attach tags to it. A tag is a handle you can pull later to expire everything holding it.
export const CACHE_TAGS = {
blogs: 'blogs',
projects: 'projects',
solutions: 'solutions',
persons: 'persons',
} as const;
const HOUR = 3600;
export const getCachedBlogs = cache(
unstable_cache(() => getCollection('blogs'), ['blogs-list'], {
tags: [CACHE_TAGS.blogs],
revalidate: HOUR,
}),
);
Three things are happening in that snippet and each one is doing separate work.
unstable_cache is the cross-request cache. The first render fetches from Firestore; every subsequent render anywhere on the site gets the cached array.
tags: ['blogs'] is the invalidation handle. When a blog is saved, that tag gets pulled and every cache entry carrying it expires — the list page, the detail pages, the homepage strip, all at once, without any of them knowing about each other.
revalidate: HOUR is a safety net, not the mechanism. If on-demand revalidation ever fails — a bad deploy, a network blip, a bug in the admin — the site is stale for at most an hour rather than forever. It should essentially never be the thing that refreshes content, and it is there for the day something else breaks.
The React cache wrapper is not redundant
The outer cache() from React is easy to mistake for a duplicate of unstable_cache. It solves a different problem: memoization within a single request.
Next calls generateMetadata and the page component separately for the same route. Both need the same post. Without React cache, that is two calls into the caching layer per request; with it, the second call returns the first one's promise.
export const getBlogBySlug = cache(
unstable_cache((slug: string) => getBySlug('blogs', slug), ['blog-by-slug'], {
tags: [CACHE_TAGS.blogs],
revalidate: HOUR,
}),
);
The mental model:
| Layer | Scope | Solves |
|---|---|---|
React cache | One request | The same data requested twice while rendering one page |
unstable_cache | All requests | Refetching data that has not changed since the last visitor |
They compose. You want both.
NOTE — Why this module is server-only
queries.ts starts with import 'server-only'. It imports next/cache, which does not work in the browser, and importing it from a client component produces a confusing build error rather than a clear one. The server-only package turns that into an explicit failure at the import site. Any file that touches caching should have it.
Invalidation without a shared secret
The revalidation endpoint is where most implementations of this pattern go wrong.
The standard approach is a secret in a header. The admin panel POSTs to /api/revalidate with Authorization: Bearer SOME_SECRET, and the route compares it against an environment variable.
That works when the caller is a server. Here the caller is the admin dashboard running in a browser, which means the secret has to be available to client-side JavaScript, which means it ships in the bundle, which means it is not a secret. Prefixing it NEXT_PUBLIC_ to make the build succeed is the moment to stop and reconsider.
The admin is already signed in with Firebase Auth, so the browser already holds something better: an ID token. The route verifies it against Firebase's REST endpoint.
async function isValidIdToken(idToken: string): Promise<boolean> {
const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY;
if (!apiKey) return false;
const res = await fetch(
`https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken }) },
);
if (!res.ok) return false;
const data = await res.json();
return Array.isArray(data.users) && data.users.length > 0;
}
No shared secret to leak, no firebase-admin dependency in the edge-facing route, and the credential is one the client is supposed to have.
It is worth being precise about the trust level: this proves the caller is some signed-in Firebase user, not specifically an admin. That is deliberate. Revalidation is non-destructive — the worst case is a cache miss and one extra Firestore read. Matching the check to the actual blast radius, rather than to the strongest check available, keeps the route simple. The Firestore rules are where write authorisation lives, and they are strict.
The tag is validated against the known set before use, so an arbitrary string cannot be passed through:
const VALID_TAGS = new Set(Object.values(CACHE_TAGS));
if (tag && VALID_TAGS.has(tag)) {
// Next 16 wants an explicit cache-life profile. An admin save should be
// live immediately, so expire it now rather than at the next window.
revalidateTag(tag, { expire: 0 });
}
The full round trip
- Admin edits a post and hits save
- The admin client writes to Firestore — allowed by the security rules for that UID
- On success, it POSTs its ID token plus
tag: 'blogs'and the affected paths to/api/revalidate - The route verifies the token, validates the tag, calls
revalidateTagandrevalidatePath - Every cached entry tagged
blogsis expired - The next request re-reads from Firestore once and repopulates the cache
Total Firestore reads caused by an edit: one per collection, on the next visit. Total reads caused by a thousand visitors between edits: zero.
Two decisions in the data layer worth stealing
Filter drafts in JavaScript, not in Firestore.
export function isPublished(blog: Blog): boolean {
return blog.status !== 'draft';
}
The obvious implementation is where('status', '==', 'published'). That silently hides every document written before the status field existed, because Firestore's where excludes documents missing the field entirely. Since the whole collection is cached anyway, filtering in memory costs nothing and treats "no status" as published, which is what a legacy document means.
The general rule: when you add a field to an existing collection, decide what its absence means, and make sure the query agrees.
Optional fields for anything added later. Every field introduced after launch is optional in the TypeScript interface — status, metaDescription, keywords, order. Firestore has no schema and no migration step, so documents written before a field existed do not have it. Typing those as required makes the compiler assert something false about production data, and the error surfaces as a runtime crash on the one old document nobody remembered.
MDX is the part that needs care
Post bodies are MDX stored as strings in Firestore and compiled on the server, which gives real control over rendering — callouts, syntax highlighting via Shiki, a table of contents from heading slugs, custom components — with zero client JavaScript for any of it.
It also executes. MDX compiles to JSX, and JSX evaluates expressions. That is safe here for exactly one reason: post content is authored by an authenticated admin, enforced by Firestore rules that deny writes to everyone else. It is a trust assumption, and it is written down at the top of the renderer, because if guest authoring is ever added it stops being true and the pipeline has to change to a sanitised, non-executing one.
An unstated trust assumption is a vulnerability with a delay on it.
One practical MDX hazard worth knowing before it costs you a production build: a bare < followed by a character in body text is parsed as the start of a JSX tag. Writing "under 16ms" as a less-than sign and a 1 broke a build here with an error message about an unexpected character, and the offending text was in a Firestore document rather than the repository — so it did not show up in a diff. Keep comparison operators inside code fences, or spell them out.
The shape of it
- Cache everything, tag it by collection
- Treat time-based revalidation as a failsafe, never the mechanism
- Invalidate on the write event, from the admin, immediately after the save
- Use React
cachefor per-request dedupe andunstable_cachefor cross-request - Authenticate the revalidation route with a credential the client legitimately holds
- Make every late-added field optional and decide what its absence means
Sixty lines, one Firestore read per collection per edit, and an author who never wonders whether the save worked.