Mume AI serves a few hundred model pages, a docs site and a chat app out of one Next.js 16 App Router codebase. Two of those routes were being prerendered. Everything else — every model page, the pricing page, the privacy policy — was rendered per request, on demand, for every visitor and every crawler.
Here is what that cost, and what fixing it returned:
| Metric | Before | After |
|---|---|---|
| Prerendered routes | 2 | 410 |
| Home TTFB | 774 ms | 170 ms |
| /models TTFB | 1,305 ms | 174 ms |
| /models HTML transferred | 300 KB | 47 KB |
| /privacy JavaScript | 1.40 MB | 0.71 MB |
| Lighthouse SEO | 92 | 100 |
One line opted out the entire tree
The root layout read a cookie to decide whether the sidebar started open:
// app/layout.tsx
export default async function RootLayout({ children }) {
const cookieStore = await cookies();
const sidebarOpen = cookieStore.get("sidebar:state")?.value !== "false";
// ...
}A dynamic server API in a layout opts every descendant out of static generation. Not the layout — the whole subtree beneath it. Every model page and every marketing page inherited per-request rendering from a single cookie read that existed to avoid a sidebar animating open on load.
This is the failure mode that makes App Router performance work frustrating: nothing errors, nothing warns, and the page renders correctly. It is simply slower for everyone, forever, and the cause is three files away from the symptom.
We moved the cookie read to the client, where the sidebar component already had the state it needed, and moved the providers that genuinely required a session into a route group layout that only wraps the signed-in surfaces. The marketing pages now ship no Firebase at all, which is most of that 1.40 MB → 0.71 MB drop on /privacy.
A fast TTFB is not evidence of a cached page
This one cost the most time. After the first round of fixes, /models was responding in about 7 ms locally, which looked like a cache hit. It was not:
curl -sI https://mume.ai/models | grep -i cache-control
# cache-control: private, no-storeThe data was cached — unstable_cache was serving the Firestore reads — but the HTML was still being rebuilt on every request. Fast, and still dynamic.
Check the header, not the stopwatch. A cached data layer in front of a dynamic render will produce timings that look exactly like static output while giving you none of the CDN benefit, and none of the crawl-budget benefit that actually moves SEO.
revalidate does nothing for a catch-all on its own
Model pages live under a catch-all, app/[...slug]/page.tsx, because a URL can resolve to either a model or an author. It already exported a revalidate window:
export const revalidate = 3600;That alone changed nothing. Next cannot prerender a dynamic segment it has no parameter values for, so it fell back to rendering on demand. generateStaticParams is what changes the cache-control header, because it is what tells the build which pages exist:
export const revalidate = 3600;
export async function generateStaticParams() {
const paths = await getCachedCatalogPaths();
return paths.map((slug) => ({ slug: [slug] }));
}Two prerendered routes became 410. Model and author pages are now built once, served from the CDN, and refreshed hourly in the background.
Ship the fields the component reads, not the document
/models was sending 300 KB of HTML because the server component passed whole Firestore documents to the client — full pricing matrices, capability flags, provider metadata, long descriptions — when the card on screen renders a name, an author, an icon and three numbers.
Projecting to the nine fields the card actually reads took the payload to 47 KB. It is an unglamorous fix and it beat every other change on that route, because RSC payload size is paid on first load, on every load, by every visitor.
Turbopack and unstable_cache do not compose the way you expect
A trap worth knowing about before it costs you an afternoon: inside an unstable_cache callback under Turbopack, a helper imported from another module can be undefined at runtime, while an identical helper defined in the same file works.
The symptom is X is not defined, in development only, surviving a cleared .next. The fix is to keep the cached callback self-contained and do the composing outside it.
Lazy instances are not lazy SDKs
We made the Firebase Storage and Functions instances lazy and re-measured expecting a win. There was none — the bundle was identical.
The instances were lazy; the imports were not. ref, uploadBytes and httpsCallable were still statically imported at the top of the module, so the SDK came along regardless. Moving to a dynamic import() at the call site is what actually removed it.
Re-measure after every optimisation. This one looked obviously correct and did nothing at all.
Never render an unknown value as a concrete one
The last fix was not a number, but it was the one users noticed most. On every page load, subscribers saw their account cycle through three states: signed out, then Free with an Upgrade button, then their actual plan.
The cause was a boolean that could not represent ignorance. profile?.metadata?.isSubscribed is undefined both when the profile has not loaded and when the user is on the free tier, and every surface reading it rendered the second meaning. So we made the uncertainty explicit:
export type Plan = "unknown" | "free" | "plus";Gates test isFree, never !isPlus. Nothing renders a plan until the plan is known. The same principle removed an upgrade prompt that was being shown to people who had already paid.
Alongside it, we replaced forty files' worth of ad-hoc spinners and skeletons — twenty-four spinner-only, eleven skeleton-only, five mixing both inside a single component — with one vocabulary and a useReadiness hook that composes several async sources and reveals once, with a floor so a cached response cannot flash a skeleton for two frames. A surface that needs three things now appears once, complete, instead of three times as each arrives.
What we would tell ourselves at the start
- Grep for dynamic server APIs in every layout before profiling anything. One
cookies()call outranks every other optimisation on this list. - Trust
cache-control, not timings. Fast and dynamic look identical from the outside. - For dynamic segments,
generateStaticParamsis the switch.revalidateis only the interval. - Payload projection beats almost everything on list pages, and nobody writes blog posts about it.
- Re-measure after every change. Two of ours did nothing, and we would have shipped both as wins.
If you are building on the same stack, the API docs are here and every model on the models page is now served from the CDN — you can check the headers yourself.