Nuxt and a Headless CMS — Serving Pages the Router Has Never Heard Of
Hero image credit: Photo by Leonid Danilov on Pexels
Part 9 of the Nuxt and .NET series.
Everything so far assumed developers decide what a page contains. This article is about the part of the application where they do not: editorial pages, where a content editor invents the URL, chooses which sections appear, and expects the result to be server-rendered and indexable without anyone deploying code.
The examples use a fictional document application and a generic headless CMS. Content platforms differ in their API names and limits, so treat the mechanisms as the transferable part.
Table of Contents
- Editors Own the URLs and the Page Structure
- One Catch-All Route
- Rendering a Section List Nobody Wrote
- Why the Content Tree Is Not One Query
- Components That Load Their Own Entry
- The Request Volume This Creates
- Making the Cascade Affordable
- When the CMS Rate-Limits You
- When an Editor Renames a Page
- What Editors Still Cannot See
- What’s Next
Editors Own the URLs and the Page Structure
Nuxt resolves routes from files. pages/documents/index.vue is served at /documents, and the set of routes is fixed when the application is built. That is an excellent property for application pages, and it is incompatible with how editorial content works.
An editor creating a landing page about a new document collection decides its path — /collections/quarterly-reports, or whatever the information architecture says this month — and decides what is on it: an introduction, a grid of teasers, a frequently-asked-questions block, a contact form. Then they reorganize all of it next quarter. No deployment is expected for any of that, and none should be needed.
So two assumptions collide. The router wants routes known at build time; the CMS hands them out at runtime. And the page component wants to know what it renders; the editor decides that too. Both need answering before a single editorial page can be served, and the answers shape the rest of the article: one route that accepts anything, and a rendering layer that dispatches on content type rather than on a template someone wrote for this page.
Worth saying why Nuxt is still the right host for this, since the mismatch is real: the CMS owns content, not the application. Authentication, forms, server middleware, the gateway from Part 3, caching, and the SSR that makes these pages indexable all still have to exist. Content-driven routing is one problem to solve inside that framework, not a reason to leave it.
One Catch-All Route
The routing half is a single file, pages/[...id].vue, which matches any path no more specific route file claims.
Precedence matters and is easy to get wrong in review: application routes keep working because file-based matching prefers the specific route, so the catch-all only sees what is left. That also means an editor can create a CMS page at a path an application route already owns, and it will never be reachable — a conflict worth surfacing to editors rather than leaving as a mystery.
Because the query runs in the page’s setup() during SSR, the server produces complete HTML with the page’s own title and meta tags taken from CMS fields. That is not a detail; it is the entire reason this approach is viable for content that needs to be indexed. A client-side fetch after an empty shell would render the same pixels and be worth considerably less.
Rendering a Section List Nobody Wrote
The page entry contains an ordered list of sections, each with a content type. Rendering it means mapping content type to component at runtime:
<component :is="componentFor(section.__typename)" :model="section" />
Writing componentFor by hand for dozens of content types — and keeping it correct as the content model evolves — is exactly the transcription Part 5 argued against, so it is generated. A generator reads the CMS schema and emits, per content type, a query that selects that type’s fields and a component shell typed to that type’s shape, plus the dispatch that routes a given type to its component.
What this does and does not buy is a distinction editors care about, so it is worth being blunt. Publishing a new entry of a content type the application already supports needs no deployment: the editor adds a teaser, and the next uncached render includes it. Introducing a new content type needs regeneration, an actual component that renders it, tests, and a release — because no generator can invent what a “comparison table” should look like. Generation removed the wiring, not the design work.
One more case has to be handled deliberately: a content type the running application does not know. That happens whenever the CMS is ahead of the deployment, which is normal during a release. The dispatch needs a fallback that renders nothing user-visible and reports the unknown type, rather than throwing and taking the page down.
Why the Content Tree Is Not One Query
A CMS content model is a graph: a page references sections, a section references teasers, a teaser references an image and a link. The obvious approach is to fetch the whole tree in one deep query, and it fails for three unrelated reasons.
Depth and size run into platform limits — response size ceilings and query complexity limits that vary by provider and that you discover when an editor nests one level deeper than anyone tested. Deep queries are also all-or-nothing: one missing reference or one oversized branch fails the entire page. And parts of the tree may not be needed at all, because sections can be conditional (Part 10), so fetching everything means paying for content nobody will see.
The alternative is to fetch every entry at minimal depth. Each generated query selects the entry’s own scalar fields in full, and reduces every reference to a stub — just enough to say what it is and where to find it:
fragment SysFields on Entry {
__typename
sys { id }
}
query cmsSection($id: String!) {
section(id: $id) {
__typename
sys { id }
heading # own field: fetched
backgroundColor # own field: fetched
bodyText { json } # own field: fetched
itemsCollection {
items { ...SysFields } # references: stubs only
}
}
}
Every entry is therefore flat and self-contained. It knows its own content and the identity of its children, and nothing about their content.
Components That Load Their Own Entry
If a parent only has stubs, the children have to resolve themselves. Each generated component accepts a model prop that may be either a stub or an already-populated entry, and asks a generated accessor for the full entry:
This is the entry-reuse pattern from Part 7, and it carries the same condition: an already-populated entry may be used directly only if it contains the fields this component needs. A stub never does, and a parent that selected two fields of a child does not satisfy a child that needs five. Get that wrong and components render with missing values instead of fetching, which looks like a content problem and is not.
Fetching by identity has one pleasant consequence: a normalized client cache deduplicates. A footer referenced from every page, or a teaser reused in three sections, is fetched once per render context and reused afterwards.
The Request Volume This Creates
Now the cost, stated plainly because it is the thing to weigh. A page with forty entries issues on the order of forty CMS requests the first time it is rendered, instead of one. On a single request that is a lot of round trips inside one SSR pass; under a traffic spike, with several server instances rendering uncached pages concurrently, it is the fastest way to find a provider’s rate limit.
This is the trade the pattern makes: many small, independently cacheable, individually resilient requests instead of one large fragile one. The pattern is only viable with the next two sections in place. Without caching and backoff, it is not a design — it is an outage waiting for traffic.
Making the Cascade Affordable
Two layers of caching, doing different jobs.
Within a single render, results are collected in a request-scoped store so that two components asking for the same entry produce one fetch. Request-scoped is doing real work in that sentence, for the reason Part 6 gave: a process-wide store would share one visitor’s content, locale and preview state with the next.
Across requests, the interesting unit is not the individual entry but the set of results a URL needs. After a successful render, that set can be written to a shared cache — Redis, in this architecture — keyed by the URL. A later request for the same URL loads the whole set in one read at the start of the render, and each component’s lookup is then satisfied locally: no per-entry round trips, no CMS calls at all.
Three conditions decide whether this is a cache or a defect. The key is a cache identity, in Part 3’s sense: URL plus locale plus preview mode plus anything else that changes what renders, including an experiment variant if one applies. The set must be invalidated when content changes, with the webhook-plus-broadcast mechanism and its non-atomic reality from Part 3. And the cold path still exists — the first request after a deployment or an invalidation pays the full cascade, so the backoff behaviour below is what keeps a cold cache from becoming a thundering herd.
When the CMS Rate-Limits You
CMS APIs have rate limits, and this fetching pattern will meet them. The mitigation is bounded retry with exponential backoff and jitter in the client chain:
Four rules make this safe rather than merely present. Honour the server’s retry guidance when it sends any, because a fixed schedule ignores what the provider knows. Add jitter, or every concurrent render retries in lockstep and recreates the spike. Retry reads only — a request that changes state may have succeeded even though its response was lost, which is why Part 3 insisted on idempotency before retries. And bound the budget: when retries are exhausted, something explicit must happen — render the page without that section, serve a stale cached set, or fail the request — and the event must be visible in monitoring. Backoff reduces pressure; it does not promise recovery, and a silent empty section is the worst of the available outcomes because nobody learns about it.
When an Editor Renames a Page
Editors changing paths is not an edge case; it is the reason they own paths. So redirects have to be editable too, which means redirect rules can live in the CMS as content: a source path and a destination. The application loads them into memory at startup and checks each incoming request in server middleware, and a publish webhook refreshes the map without a restart.
The mechanics are simple, and the two traps are worth naming. A redirect map assembled from editorial input needs validation on write — a loop, a redirect to a path that does not exist, or a rule that shadows an application route are all one careless entry away, and all three are better caught when the entry is published than when a visitor hits them. And the in-memory map is per worker, so “the redirect is live” means every worker has processed the refresh; a worker that was restarting missed it, which is the same non-atomic invalidation problem as the content cache, with a shorter fuse because a stale redirect is visible immediately.
What Editors Still Cannot See
Editorial pages now work: any path an editor invents is served, server-rendered, indexable, assembled from components the generator wired up, and affordable under load because of the cache and the backoff.
What this does not give anyone is an explanation. When an editor looks at a page and their new section is not there, the reason could be that it is unpublished, that the shared cache is still serving the previous result set, that the content type is not supported by the running deployment, or that the section carries a visibility rule that excluded it for this request. All four look identical in a browser, and three of them are consequences of the design choices in this article. Making that distinguishable is the subject of the next one.
The rule I would take from this article, before adding a CMS-driven page type to any application: write down, for one representative page, how many CMS requests it makes cold, how many warm, what its cache identity is, and what it renders when the CMS says no. If those four answers are not known, the pattern is being adopted on the strength of its first demo, which is always fast because the tree is small and the cache is empty of everyone else.
What’s Next
- Part 10: Conditional Content and Live Preview — Why a section is missing, and how an editor sees changes before publishing.
- Part 11: A/B Testing at the SSR Level — What happens to cache identity when two visitors should see different pages.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net