Everything so far has assumed that we developers decide what a page contains. This article is about the part of the application where we don’t: editorial pages, where a content editor invents the URL, picks which sections appear, and fully expects the result to be server-rendered and indexable without anybody deploying a line of code.

The examples use a fictional document application and a generic headless CMS. Content platforms differ in their API names and their limits, so treat the mechanisms as the transferable part.

Table of Contents

Editors Own the URLs and the Page Structure

Nuxt resolves routes from files. pages/documents/index.vue gets served at /documents, and the set of routes is fixed the moment the application is built. That’s an excellent property for application pages, and it’s completely incompatible with how editorial content works.

An editor creating a landing page about a new document collection decides its path, so /collections/quarterly-reports, or whatever the information architecture happens to say this month, and decides what goes on it: an introduction, a grid of teasers, a frequently-asked-questions block, a contact form. And then they reorganize the whole lot next quarter. Nobody expects a deployment for any of that, and none should be needed.

So we’ve got two assumptions colliding. The router wants its routes known at build time, and the CMS hands them out at run time. The page component wants to know what it renders, and the editor decides that too. Both need answering before we can serve a single editorial page, and the answers shape the rest of this article: one route that accepts anything at all, and a rendering layer that dispatches on content type rather than on a template somebody wrote for this specific page.

It’s worth saying why Nuxt is still the right host for this, since the mismatch is real enough: 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 somewhere. Content-driven routing is one problem to solve inside that framework, not a reason to walk away from it.

One Catch-All Route

The routing half is one single file, pages/[...id].vue, which matches any path that no more specific route file has claimed.

flowchart TB R["Request /collections/quarterly-reports"] --> M{"Matching page file?"} M -->|"/documents/42 → pages/documents/[id].vue"| APP["Application page<br/>(developer-owned)"] M -->|"no specific file"| CA["pages/[...id].vue"] CA --> Q["Query the CMS for a page<br/>whose path equals this URL"] Q --> F{"Found?"} F -->|No| E404["404"] F -->|Yes| P["Page entry:<br/>metadata + ordered section list"] P --> REND["Render each section"]

Precedence matters here, and it’s easy to wave through in review: application routes carry on working because file-based matching prefers the specific route, so the catch-all only ever sees what’s left over. Which also means an editor can create a CMS page at a path an application route already owns, and it will never be reachable. That’s a conflict worth surfacing to editors rather than leaving as a mystery for them to solve.

And 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 isn’t a detail, it’s 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 exact same pixels and be worth a great deal less.

Rendering a Section List Nobody Wrote

The page entry holds an ordered list of sections, each one with a content type. Rendering that means mapping content type to component at run time:

<component :is="componentFor(section.__typename)" :model="section" />

Writing componentFor by hand for dozens of content types, and then keeping it correct as the content model evolves, is exactly the transcription Part 5 argued against. So we generate it. A generator reads the CMS schema and emits, per content type, a query selecting 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 doesn’t buy you is a distinction editors care about a great deal, so let me be blunt about it. Publishing a new entry of a content type the application already supports needs no deployment at all: 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 on earth can invent what a “comparison table” ought to look like. Generation took away the wiring, not the design work.

There’s one more case you have to handle deliberately: a content type the running application has never heard of. That happens whenever the CMS is ahead of the deployment, which is perfectly normal during a release. So the dispatch needs a fallback that renders nothing user-visible and reports the unknown type, rather than throwing and taking the whole page down with it.

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 move is to fetch the whole tree in one deep query, and it fails for three entirely unrelated reasons.

Depth and size run straight into platform limits, meaning response size ceilings and query complexity limits which vary by provider and which you tend to discover the day an editor nests one level deeper than anybody 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 (that’s Part 10), so fetching everything means paying for content nobody is ever going to 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, which is 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
    }
  }
}

So every entry ends up flat and self-contained. It knows its own content and the identity of its children, and absolutely nothing about their content.

Components That Load Their Own Entry

If a parent only holds stubs, then the children have to resolve themselves. Each generated component accepts a model prop which may be either a stub or an already-populated entry, and asks a generated accessor for the full entry:

flowchart TB PG["Page entry<br/>stubs for sections, header, footer"] --> S["Section component<br/>loads section by id"] S --> T["Teaser component<br/>loads teaser by id"] T --> I["Image component"] T --> L["Link component"] PG --> H["Header component"] PG --> F["Footer component"] S --> D{"Dispatch on __typename<br/>for each stub in items"}

This is the entry-reuse pattern from Part 7, and it comes with the same condition attached: an already-populated entry may only be used directly if it contains the fields this component needs. A stub never does, and a parent that selected two fields of a child doesn’t satisfy a child that needs five. Get that wrong and your components render with missing values instead of fetching, which looks like a content problem and isn’t one.

Fetching by identity has one rather pleasant consequence: it makes deduplication automatic in a normalized client cache, meaning one that stores each entry once under its own identifier rather than storing whole query responses. Because every component asks for an entry by id, two components asking for the same id land on the same cache record. So a footer referenced from every page, or a teaser reused across three sections, gets fetched once per render context and reused from then on.

The Request Volume This Creates

And now the cost, stated plainly, because this is the thing you actually have to weigh. A page with forty entries issues something like forty CMS requests the first time it renders, rather than one. On a single request that’s an awful lot of round trips inside one SSR pass. Under a traffic spike, with several server instances rendering uncached pages at the same time, it’s the fastest route I know to finding a provider’s rate limit.

That’s the trade this pattern makes: many small, independently cacheable, individually resilient requests instead of one big fragile one. And the pattern is only viable with the next two sections in place. Without caching and backoff it isn’t a workable design at all, because the first burst of real traffic turns it into an outage.

Making the Cascade Affordable

Two layers of caching, doing two different jobs.

Within a single render, results get collected in a request-scoped store, so that two components asking for the same entry produce exactly one fetch. “Request-scoped” is doing real work in that sentence, for the reason Part 6 gave: a process-wide store would happily share one visitor’s content, locale and preview state with the next one.

Across requests, the interesting unit isn’t the individual entry, it’s the set of results a URL needs. After a successful render, that whole 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 entire 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 whatsoever.

flowchart TB REQ["SSR request for a URL"] --> LOAD{"Result set in shared cache<br/>for this cache identity?"} LOAD -->|Yes| WARM["One read; components resolve locally"] LOAD -->|No| COLD["Components fetch entries from the CMS"] COLD --> STORE["Persist the result set after render"] WARM --> HTML["Rendered HTML"] STORE --> HTML

Three conditions decide whether this ends up being a cache or a defect. The key has to be a cache identity in Part 3’s sense: URL plus locale plus preview mode plus anything else at all that changes what renders, including an experiment variant if one applies. The set has to be invalidated when content changes, using the webhook-plus-broadcast mechanism and its decidedly non-atomic reality from Part 3. And the cold path still exists, so the first request after a deployment or an invalidation pays the full cascade. Worse, it’s rarely just one request. Invalidate a popular page and every visitor arriving in the next second misses the cache, and each of those renders kicks off its own forty-request cascade. That’s a thundering herd, where the load on the CMS is proportional to your concurrent traffic rather than to the one result that actually needed recomputing. The backoff behaviour below is what stops that taking the CMS down, and a single-flight lock, letting one render populate the cache while the others either wait for it or serve the previous set, is what stops the herd forming in the first place.

When the CMS Rate-Limits You

CMS APIs have rate limits, and this fetching pattern is going to meet them: the provider answers 429 Too Many Requests instead of handing over the entry you asked for. Retrying immediately only makes things worse, so the mitigation is a bounded retry with exponential backoff and jitter in the client chain. Backoff means each attempt waits longer than the last one, typically doubling the delay each time from a short initial wait, so the pressure you’re applying drops as the failures continue. Jitter means adding a small random amount to each wait, and that matters because all fifty entry requests failed at the same moment and would otherwise all retry at the same moment too. Bounded means there’s a fixed number of attempts and a deadline, after which your code does something deliberate rather than waiting around forever.

sequenceDiagram participant C as CMS client participant CMS as Content API C->>CMS: Fetch entry CMS-->>C: 429 Too Many Requests (Retry-After) C->>C: Wait honouring Retry-After, plus jitter C->>CMS: Retry 1 CMS-->>C: 429 C->>C: Increase delay within the retry budget C->>CMS: Retry 2 CMS-->>C: 200 OK

Four rules make this actually safe rather than merely present. Honour the server’s retry guidance whenever it sends any, because a fixed schedule ignores what the provider knows about its own state. Add jitter, or every concurrent render retries in lockstep and recreates the spike you were trying to avoid. Retry reads only, because a request that changes state may well have succeeded even though its response got lost, which is exactly why Part 3 insisted on idempotency before retries. And bound the budget: when the retries are exhausted, something explicit has to happen, whether that’s rendering the page without that section, serving a stale cached set, or failing the request outright, and the event has to be visible in monitoring. Backoff reduces pressure. It doesn’t promise recovery, and a silently empty section is the worst of the available outcomes precisely because nobody ever finds out about it.

When an Editor Renames a Page

Editors changing paths isn’t an edge case. It’s the reason they own the paths in the first place. So redirects have to be editable too, which means the redirect rules can live in the CMS as content: a source path and a destination. The application loads them into memory at startup, checks each incoming request in server middleware, and a publish webhook refreshes the map without needing a restart.

The mechanics are simple enough, and the two traps are worth naming. A redirect map assembled from editorial input needs validation on write, because a loop, a redirect to a path that doesn’t exist, or a rule that shadows an application route are all exactly one careless entry away, and all three are much better caught when the entry gets published than when a visitor walks into them. And the in-memory map is per worker, so “the redirect is live” really means every worker has processed the refresh. A worker that happened to be restarting missed it, which is the same non-atomic invalidation problem as the content cache, just with a shorter fuse, because a stale redirect is visible immediately.

What Editors Still Cannot See

So editorial pages now work. Any path an editor invents gets served, server-rendered, indexable, assembled from components the generator wired up, and affordable under load thanks to the cache and the backoff.

What none of this gives anybody is an explanation. When an editor looks at a page and their new section simply isn’t there, the reason might be that it’s unpublished, that the shared cache is still serving the previous result set, that the content type isn’t supported by the running deployment, or that the section carries a visibility rule which excluded it for this particular request. All four of those look absolutely identical in a browser, and three of them are consequences of the design choices in this article. Making them distinguishable is what the next one is about.

And here’s the rule I’d take from this article before adding a CMS-driven page type to any application. For one representative page, write down how many CMS requests it makes cold, how many it makes warm, what its cache identity is, and what it renders when the CMS says no. If you don’t know those four answers, then the pattern is being adopted on the strength of its first demo. Which is always fast, of course, because the content tree is still small and nobody else’s traffic is competing for the cache yet.

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.