Ninth in a series about migrating from legacy architectures to a modern Nuxt 4 stack.
The Fundamental Tension
Nuxt is a file-based router. You create pages/products/index.vue, and the framework serves it at /products. Routes are known at build time. The directory structure is the URL structure.
Contentful — a headless CMS — is the opposite. Content editors define the URL structure at runtime. They create pages, set slugs, rearrange the information architecture — and the application must serve whatever path they invent, without a code deployment.
This is not a bug — it is a mismatch between two design philosophies. Any team that integrates a headless CMS with Nuxt, Next.js, or any file-based router runs into it.
So why use Nuxt at all? Because the CMS owns the content, not the application. A production web application still needs SSR, authentication, form validation, tracking, server middleware, and dozens of other concerns that only a full application framework covers. The routing mismatch is one problem to solve — not a reason to abandon the framework that solves everything else.
The Solution: A Single Catch-All Route
One file: pages/[...id].vue. Nuxt’s catch-all route matches every path not handled by a more specific route file.
flowchart TB
subgraph Nuxt_Route_Resolution
A[/Request: /about/] -->|exists| B[pages/about.vue]
C[/Request: /service/faq/] -->|exists| D[pages/service/faq.vue]
E[/Request: /anything/else/] -->|no specific file| F["pages/...id.vue<br/>catch-all"]
end
The catch-all route does one thing: it queries Contentful for a page entry matching the current URL path. If Contentful returns a page, the component renders it. If not, it returns a 404.
flowchart TB
A["Browser requests<br/>/products/premium-plan"] --> B["pages/...id.vue"]
B --> C["Extract path<br/>'/products/premium-plan'"]
C --> D["GraphQL query<br/>pageByPath(path)"]
D --> E{Contentful page found?}
E -- No --> F[Return 404]
E -- Yes --> G["Contentful returns Page entry<br/>type, title, sections"]
G --> H["Render ‹app-page :page='page' /›"]
H --> I["Iterate sections"]
I --> J["‹component :is=#quot;resolve(section.type)#quot; /›<br/>for each section"]
The CMS owns the URL structure; the application owns the rendering. Neither constrains the other.
Code Generation: From Content Model to Running Components
A CMS page is not a single component — it is a list of content sections, each with a type. The application maps each content type to a Vue component and renders them dynamically. Doing this wiring by hand for dozens of content types does not scale. A code generator reads the Contentful content model schema via GraphQL introspection and produces everything needed.
For every content type, the generator outputs:
- A GraphQL query that requests all scalar fields for that type, so the application always fetches exactly what the Contentful schema defines.
- A Vue component that receives the Contentful entry as a strongly typed prop, with full TypeScript support for every field out of the box.
flowchart TB
subgraph Contentful
P["Page: 'Homepage'"] --> S1[HeroSection]
P --> S2[TeaserGrid]
P --> S3[ProductComparison]
P --> S4[TestimonialCarousel]
P --> S5[FAQAccordion]
P --> S6[ContactForm]
end
subgraph Generated_Vue_Components
C1["‹HeroSection :model='...' /›"]
C2["‹TeaserGrid :model='...' /›"]
C3["‹ProductComparison :model='...' /›"]
C4["‹TestimonialCarousel :model='...' /›"]
C5["‹FAQAccordion :model='...' /›"]
C6["‹ContactForm :model='...' /›"]
end
S1 --> C1
S2 --> C2
S3 --> C3
S4 --> C4
S5 --> C5
S6 --> C6
When content editors add a new content type in Contentful, the build pipeline generates the corresponding query and component. The developer only supplies rendering logic and styling — data fetching, types, and component mapping are handled. A content editor adds a new section, and the next page load renders it. No deployment, no PR, no coordination with the development team.
How Content Entries Are Fetched: The Stub-and-Load Pattern
A Contentful content model is a graph. A page references sections, sections reference teasers, teasers reference images and links. Loading the entire graph in one request would hit Contentful’s API response size limits, transfer unnecessary data, and create an all-or-nothing loading bottleneck.
Instead, every Contentful entry is fetched at minimal depth, and every reference is initially just a stub.
What Is a Stub?
A stub is a content entry stripped to two fields: __typename (content type) and sys.id (unique identifier). No title, no body text, no image URLs — just enough to identify what the entry is and where to fetch it.
flowchart LR
subgraph Full_Entry
A["__typename: 'Section'"]
B["sys.id: 'abc123'"]
C["title: 'Our Services'"]
D["backgroundColor: 'white'"]
E["items: [...]"]
F["sectionText: {...}"]
end
subgraph Stub
G["__typename: 'Section'"]
H["sys.id: 'abc123'"]
end
The SysFields GraphQL fragment — automatically applied to all reference fields in generated queries — implements this:
fragment SysFields on Entry {
__typename
sys {
id
}
}
Minimal-Depth Queries
When a generated query fetches a content type, it requests all scalar fields of that type (strings, numbers, booleans, rich text) but only the SysFields stub for every reference. Collections of references return arrays of stubs.
# Generated Query: cfSection($id)
query cfSection($id: String!) {
section(id: $id) {
__typename
sys { id }
anchor # scalar: fetched in full
backgroundColor # scalar: fetched in full
sectionHeadingH2 # scalar: fetched in full
sectionText { ... } # rich text: fetched in full
itemsCollection {
items {
__typename # stub: only typename + id
sys { id }
}
}
mediaCollection {
items {
__typename # stub: only typename + id
sys { id }
}
}
}
}
Each entry is self-contained and flat. It knows its own data and the identities of its children, but not their content.
How Vue Components Load the Tree
Every generated Vue component follows the same pattern:
- It accepts a
modelprop — which can be either a stub (just__typenameandsys.id) or a fully loaded entry. - It calls a generated composable (
useCf{ContentType}Entry) that delegates touseOrLoadCfEntry. useOrLoadCfEntryalways triggers a GraphQL fetch by ID — but Apollo’s cache handles the rest.
flowchart TB
A["Component receives prop<br/>model: stub or full entry"] --> B["useCfSectionEntry(() => props.model)"]
B --> C["useOrLoadCfEntry(fetchById, () => model)"]
C --> D{Apollo cache hit?}
D -- Yes --> E["Return cached data<br/>No network request"]
D -- No --> F[Fetch from CMS by ID]
F --> G[Store in Apollo cache]
G --> E
The component renders its scalar fields and passes each child reference to the next generated component, repeating the cycle.
The Rendering Cascade
On first page load, this creates a cascade of individual fetches, each triggered when a Vue component mounts:
flowchart TB
A["Browser requests<br/>/products/premium-plan"] --> B["cf-page loads Page entry<br/>(title, slug, stubs for sections, header, footer)"]
B --> C["cf-section loads Section 'abc123'<br/>(scalars + stubs)"]
C --> D[cf-teaser loads Teaser 'def456']
D --> E[cf-image loads Image 'ghi789']
D --> F[cf-link loads Link 'jkl012']
C --> G[cf-slider loads Slider 'mno345']
B --> H[cf-header loads Header 'pqr678']
B --> I[cf-footer loads Footer 'stu901']
Each component loads exactly one entry from Contentful. The parent passes its child stubs as props; each child component independently calls useOrLoadCfEntry to fetch the full entry by ID. The Vue component tree is the assembled page — it mirrors the content graph through composition, each component owning its own data.
Every useOrLoadCfEntry call goes through useGraphQlQuery, which checks a per-URL cache before hitting GraphQL. On the first SSR render of a URL, each query result is collected in a request-scoped Map and persisted to Redis after render. On subsequent requests, the plugin loads the entire Map in a single read at request start. Each component’s query then short-circuits from the Map — no Apollo Client, no Apollo Server, no Contentful call. This turns 40 individual round-trips into one bulk cache lookup.
flowchart TB
subgraph First_SSR_Request
A1[Component query] --> B1[useGraphQlQuery]
B1 --> C1{URL-scoped Map has entry?}
C1 -- No --> D1[Call Contentful via GraphQL]
D1 --> E1[Store result in Map]
end
subgraph Persist
E1 --> F1["Persist Map to Redis<br/>per URL after render"]
end
subgraph Subsequent_Requests
G1[Load Map from Redis at request start] --> H1[Component query]
H1 --> I1[useGraphQlQuery]
I1 --> J1{Map has entry?}
J1 -- Yes --> K1["Return cached result<br/>Skip Apollo/Contentful"]
end
The Polymorphic Dispatch Component
Reference collections in Contentful are polymorphic — a section’s items can contain teasers, sliders, images, FAQ accordions, or anything else. The generated code routes each stub to the correct component based on __typename:
flowchart TB
A["cf-section-items-item<br/>receives stub"] --> B{__typename}
B -- "Teaser" --> C["‹cf-teaser :model='stub' /›"]
B -- "Slider" --> D["‹cf-slider :model='stub' /›"]
B -- "Image" --> E["‹cf-image :model='stub' /›"]
B -- "FAQ" --> F["‹cf-faq :model='stub' /›"]
B -- other/unknown --> G["‹contentful-error /›"]
Each resolved component calls its own useCf{Type}Entry composable to fetch the full entry by ID. The developer never writes dispatch logic — the code generator produces the complete dispatch tree from the schema.
Apollo Cache as the Deduplication Layer
Every entry is fetched by unique ID, so Apollo’s InMemoryCache deduplicates naturally. If the same entry appears in multiple places — shared footer, reused teaser, global navigation — it is fetched once. Subsequent references reuse the cached result.
Conditional Entries and Deferred Loading
Some Contentful entries have conditions attached — visibility rules evaluated at runtime (time-based, A/B test variants, feature flags). When conditions hide an entry, its children are never loaded. The content tree remains intentionally incomplete.
When a previously cached page is rendered again and a condition changes — making a previously hidden entry visible — the stub for that entry is still present in the parent’s data. The child component mounts, calls useOrLoadCfEntry, and the entry is fetched from Contentful for the first time. The component tree grows to include the newly visible branch.
The page is eventually complete: as conditions change and new branches become visible, the corresponding components mount and load their entries on demand, without requiring a full page reload.
flowchart TB
A[Parent entry with stub child] --> B{Condition true now?}
B -- No --> C["Child component not mounted<br/>No fetch"]
B -- Yes --> D[Child component mounts]
D --> E[useOrLoadCfEntry for child ID]
E --> F["Fetch from Contentful<br/>(if not cached)"]
F --> G[Render newly visible subtree]
Why This Pattern Works
- No deep queries: Each fetch returns a single, flat object. No nested queries that risk hitting Contentful API limits.
- Incremental assembly: The page builds itself component by component — each one independently loading its own data. No all-or-nothing loading.
- Automatic deduplication: Apollo’s cache ensures each entry is fetched exactly once, regardless of how many components reference it.
- Cache-friendly: Once all components have rendered during SSR, their individual query results are collected and cached as a set per URL (detailed in the following article).
- Condition-safe: Entries behind conditions are loaded on demand. The tree adapts to runtime state without refetching the entire page.
The Trade-Off: Request Volume
The downside is obvious: many small requests instead of one large one. A page with 40 content entries issues 40 individual GraphQL requests to Contentful on first load. This hits rate limits faster, especially under traffic spikes when multiple server instances render concurrently. Exponential backoff (described below) and aggressive page-level caching (next article) exist specifically to mitigate this. Without caching, this pattern would not scale.
SEO and SSR: The Critical Combination
Content-driven routing only works for SEO if the server renders the full page. Search engines need complete HTML, not a JavaScript shell that fetches content on the client.
Because Nuxt is an SSR framework, the catch-all route executes on the server:
- The server receives the request for
/products/premium-plan. - The catch-all route’s
setup()function runs server-side. - The GraphQL query fetches the page data from Contentful.
- Vue renders the full component tree to HTML.
useHead()sets the,, and Open Graph tags from Contentful fields.- The browser receives complete, crawlable HTML.
sequenceDiagram
participant User
participant Browser
participant Nuxt_SSR as Nuxt SSR Server
participant Contentful
User->>Browser: Request /products/premium-plan
Browser->>Nuxt_SSR: HTTP GET /products/premium-plan
Nuxt_SSR->>Nuxt_SSR: Match pages/[...id].vue<br/>run setup()
Nuxt_SSR->>Contentful: GraphQL pageByPath(path)
Contentful-->>Nuxt_SSR: Page data (title, sections, SEO fields)
Nuxt_SSR->>Nuxt_SSR: Render Vue tree to HTML<br/>useHead() for meta tags
Nuxt_SSR-->>Browser: HTML response<br/>(fully rendered content)
Browser-->>User: Display crawlable page
Search Engine Crawler receives:
┌─────────────────────────────────────────────┐
│ <html> │
│ <head> │
│ <title>Premium Plan — Our Products</title>
│ <meta name="description" │
│ content="Discover our..." /> │
│ <meta property="og:image" ... /> │
│ </head> │
│ <body> │
│ <section class="hero">...</section> │
│ <section class="features">...</section> │
│ <section class="faq">...</section> │
│ <!-- All content rendered, no JS needed -->
│ </body> │
│ </html> │
└─────────────────────────────────────────────┘
Redirects from Contentful
URLs change. Subscription plans get renamed, pages reorganized, campaigns expire. In traditional architectures, redirect rules live in web server config and need a deployment to change.
In a large enterprise application, redirect rules can be content entries. Editors create a redirect with a source path and destination. The application loads all redirects into an in-memory map at startup and checks incoming requests against it in server middleware.
When Contentful publishes a change, a webhook fires and the application refreshes the redirect map — no restart, no deployment. A content editor adds a redirect and sees it take effect within seconds.
flowchart TB
A[Content editor creates/updates Redirect entry] --> B[Contentful publishes change]
B --> C[Webhook to Application]
C --> D[Refresh in-memory redirect map]
subgraph Request_Flow
E[Incoming HTTP request] --> F{Path in redirect map?}
F -- No --> G[Continue normal routing]
F -- Yes --> H[Return 301/302 to target URL]
end
Content Security Policy from Contentful
The same pattern applies to Content Security Policy. Instead of hardcoding CSP directives in application config, the CSP is stored as a Contentful entry. A server plugin fetches it at runtime and applies it as an HTTP header.
The security team can update the CSP — add a script source, remove a deprecated domain — without involving developers or waiting for a deployment.
flowchart TB
A["Security team edits CSP entry<br/>in Contentful"] --> B[Contentful publishes CSP]
B --> C["Webhook or periodic fetch<br/>by server plugin"]
C --> D[Update in-memory CSP config]
E[Incoming HTTP response] --> F["Attach CSP header<br/>from current config"]
Rate Limiting and Resilience
Contentful’s APIs have rate limits. Hitting them during traffic spikes means pages render without content or return errors.
The mitigation is exponential backoff in the GraphQL client chain:
sequenceDiagram
participant Client as GraphQL Client
participant Contentful
Client->>Contentful: Request
Contentful-->>Client: 429 Too Many Requests
Client->>Client: Wait 300ms (+ jitter)
Client->>Contentful: Retry #1
Contentful-->>Client: 429
Client->>Client: Wait 600ms (+ jitter)
Client->>Contentful: Retry #2
Contentful-->>Client: 429
Client->>Client: Wait 1200ms (+ jitter)
Client->>Contentful: Retry #3
Contentful-->>Client: 200 OK
The retry link handles both 429 (rate limit) and 5xx (server error) responses. Backoff increases exponentially up to 10 seconds. Jitter prevents thundering herd problems when multiple replicas retry at once.
From the user’s perspective, the page loads slightly slower during a rate limit event. The system self-heals. Logs show warnings, not errors.
Preview Mode: Real-Time WYSIWYG
Content editors need to see changes before publishing. Contentful provides a Preview API that returns draft content, but integrating it with an SSR application takes some work.
The system supports two modes:
| Mode | API | Cache | Use Case |
|---|---|---|---|
| Published | Delivery API | Aggressive caching | Production, public traffic |
| Preview | Preview API | No caching | CMS editor preview, draft content |
A preview runtime flag switches the entire GraphQL client chain from the Delivery API to the Preview API. When an editor views the application inside Contentful’s preview iframe, they see draft content rendered by the same SSR pipeline that serves production traffic.
Contentful’s Live Preview SDK goes further: when the editor changes a field in the Contentful sidebar, a postMessage event fires to the application iframe. The application re-renders the affected component — near-real-time WYSIWYG without a page reload.
sequenceDiagram
participant Editor as Content Editor
participant CMS as Contentful UI
participant App as Nuxt App (iframe)
participant GraphQL as GraphQL Client
Editor->>CMS: Edit field value
CMS->>CMS: Use Preview API data
CMS-->>App: postMessage(change event)
App->>App: Determine affected component
App->>GraphQL: Fetch draft content (Preview API<br/>via preview flag)
GraphQL-->>App: Updated draft data
App-->>Editor: Re-render affected component<br/>inside preview iframe
Lessons Learned
The catch-all route is an enabler, not a hack
A single pages/[...id].vue serving all CMS-driven pages can feel wrong to developers used to explicit route files. It is the correct pattern for content-driven applications. The CMS is the router. The catch-all route is the adapter between the CMS URL model and Nuxt’s file-based routing.
Content editors should not need developers for URL changes
Any system where URL changes require a deployment creates a bottleneck. The dev team becomes a gatekeeper for content operations. Storing redirects and URL structures in the CMS removes that bottleneck.
Runtime configuration from the CMS is a pattern, not a one-off
CSP from the CMS, redirects from the CMS, feature flags from the CMS — the pattern generalizes. Anything that needs to change without a deployment is a candidate for CMS-managed configuration. The requirement is reliable invalidation (webhooks + in-memory refresh) so changes propagate quickly.
Preview integration is harder than it looks
Making SSR work with Contentful’s preview mode means switching the API source, disabling all caches, handling the three-level iframe hierarchy (CMS → application → DevTools), and coordinating live update events across component boundaries. Budget real time for this.
What’s Next
- Article 6: Performance Optimization — Chasing That 100% Lighthouse Score — Multi-tier caching, deferred hydration, and the techniques that add up to near-perfect scores.
- Article 7: Type-Safe Form Generation from GraphQL Introspection — How the backend schema drives form rendering and validation without any frontend duplication.
- Article 8: The Modular Architecture — Independent Building Blocks — How 35+ custom Nuxt modules create a maintainable, extensible system.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.





