Architecture & system design

Rendering and data architecture

Deciding rendering per route with a criteria table, the caching layers from browser to database, server-side aggregation versus client waterfalls, realtime and auth architecture, multi-tenant theming and i18n routing, search, edge versus region placement, resilience, observability as a design input, and migration paths.

Rendering and data are where frontend architecture meets infrastructure, and where Nuxt gives you the most knobs: universal, static, swr, isr, SPA, islands and streaming per route; Nitro caching and storage; edge or regional deployment. The design skill is choosing per route and per data type with explicit criteria, and knowing what each choice costs.

Know

  • Rendering criteria per route: does a crawler or link preview need it (SEO), is it personalised, how fresh must it be, how expensive to render, how interactive after load. The outcomes: prerender (public, stable), swr/isr (public, changing, shareable by URL), SSR without cache (query-dependent or lightly personalised, still crawlable), SPA (ssr: false; private, interaction-heavy, not indexed), server components/islands for heavy static fragments inside any mode, lazy hydration for below-the-fold interactivity, streaming where TTFB on slow data matters (experimental).
  • Caching layers, outermost first: browser cache and bfcache → CDN (URL plus Vary) → Nitro response cache (swr, defineCachedEventHandler) → function cache (cachedFunction) → application caches (Redis) → database (query cache, replicas). Each has a key, a TTL and an invalidation path; personalised data lives only in private layers. The server caching page has the mechanics.
  • Data fetching topology: the server (Nitro as BFF) aggregates upstream calls in parallel and returns one payload for the first render; the client fetches only what changes after load or is personalised; client-side waterfalls (component A fetches, then child B fetches) are eliminated by fetching at the page level with Promise.all or by hoisting keys. Payload size is a budget (pick, DTOs).
  • Realtime architecture: SSE for server-to-client feeds (works on serverless), WebSockets for bidirectional low-latency (stateful servers or Durable Objects), polling with ETags for slow data; versioned messages, a pub/sub backplane for multiple instances, and authentication at the handshake (realtime page).
  • Auth architecture: an HttpOnly first-party session cookie readable by SSR and forwarded to the BFF; identity provider via OIDC on the server; authorisation on every server request; the edge can validate sessions for static-plus-private splits. Personalised HTML never shares a cache key with public HTML.
  • Multi-tenancy: tenant resolution from host, path or session in server middleware into event.context.tenant; per-tenant tokens (data-brand or --ui-* values) rather than per-tenant components; tenant in every cache key and every query (or row-level security); per-tenant feature flags and limits.
  • i18n architecture: prefixed locale routes for SEO with hreflang, lazy-loaded message files, per-locale prerendering or swr, Intl formatting with locale and time zone from shared inputs, content translated at the source (CMS or content directories), RTL through logical properties (i18n page).
  • Search: small catalogues can search client-side over a prefetched index; anything larger uses a search engine (Meilisearch, Typesense, Algolia, Postgres full-text for modest needs) fed by an indexing task on content changes, queried through the BFF with caching and rate limits.
  • Edge versus region: edge runtimes minimise latency to users and scale to zero, but the database usually lives in one region, so data-heavy requests pay a round trip per query; mitigate with connection accelerators (Hyperdrive), read replicas, caching at the edge, or keep data-heavy routes regional and serve static and cached pages from the edge. Long-lived connections and heavy CPU favour regional Node servers.
  • Resilience: timeouts and fallbacks per upstream, stale-if-error, error boundaries per section so one failing widget does not take the page, graceful degradation of realtime to polling, queue-backed writes for third-party outages, and idempotent retries.
  • Observability as design input: decide up front what to measure: Core Web Vitals by route, server p95 per endpoint, cache hit ratios, error rates by release, and business events; correlate with request ids across CDN, Nitro and database.
  • Migration paths: SPA → SSR (find window access, module-level state, non-deterministic render; adopt route by route with ssr: false as the default and flip routes over), monolith → BFF (introduce Nitro routes in front of the old API and move shaping there), Nuxt 3 → 4 → 5 (codemods, compatibility versions), REST → typed procedures (oRPC alongside REST), region → edge for the static half first.

How it works

The decision table, then the configuration that implements it:

Route              Crawl?  Personal?  Freshness   Mode                 Cache key / notes
/                  yes     no         deploy      prerender            CDN; HTML must-revalidate
/blog/**           yes     no         hourly      swr 3600             URL; purge on publish via webhook
/c/**, /p/**       yes     no         minutes     swr 300              URL + locale + currency (Vary)
/search            yes*    no         live        SSR, no HTML cache   cachedFunction per normalised query, 30s
/cart, /checkout   no      yes        live        SSR private          no shared cache; session cookie
/account/**        no      yes        live        SPA (ssr: false)     API private, no-store
/live/**           no      yes        realtime    SSR shell + SSE      versioned events; Redis pub/sub
nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { swr: 3600 },
    '/c/**': { swr: 300 },
    '/p/**': { swr: 300 },
    '/search': { headers: { 'cache-control': 'private, no-store' } },
    '/cart': { headers: { 'cache-control': 'private, no-store' } },
    '/checkout/**': { headers: { 'cache-control': 'private, no-store' } },
    '/account/**': { ssr: false, appMiddleware: 'auth' },
    '/api/catalog/**': { cache: { maxAge: 300, swr: true, varies: ['accept-language'] } },
    '/api/account/**': { headers: { 'cache-control': 'private, no-store' } },
  },
  nitro: { storage: { cache: { driver: 'cloudflare-kv-binding', binding: 'CACHE' } } },
})

Tenant resolution and a cache key that respects tenancy and locale:

server/middleware/tenant.ts
export default defineEventHandler(async (event) => {
  const host = getRequestHost(event)
  event.context.tenant = await resolveTenant(host) // cached: subdomain -> tenant id, brand tokens, feature flags
  if (!event.context.tenant) throw createError({ status: 404, statusText: 'Unknown tenant' })
})
server/api/catalog/featured.get.ts
export default defineCachedEventHandler(async (event) => {
  const { tenant } = event.context
  return listFeatured(useDb(), { tenantId: tenant.id, locale: getLocale(event) })
}, {
  name: 'featured',
  maxAge: 300,
  swr: true,
  getKey: event => `${event.context.tenant.id}:${getLocale(event)}`, // never a shared entry across tenants
})

Eliminating a client waterfall with server-side aggregation and a single payload:

app/pages/p/[slug].vue
<script setup lang="ts">
const { params } = useRoute()
// One server-side aggregation instead of product -> then reviews -> then related in the browser.
const { data } = await useFetch(`/api/pdp/${params.slug}`) // BFF route runs Promise.all over product, reviews summary, related
// Personalised, uncacheable part fetched client-side after hydration:
const { data: stock } = useLazyFetch(() => `/api/stock/${params.slug}`, { server: false })
</script>

Resilience in the BFF route the page relies on:

server/api/pdp/[slug].get.ts
export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, 'slug')!
  const [product, reviews, related] = await Promise.all([
    getProduct(useDb(), slug),                                                           // required: a failure is a 404/500
    getReviewSummary(slug).catch(() => null),                                            // optional: degrade the section
    getRelated(slug, { signal: AbortSignal.timeout(800) }).catch(() => []),              // optional and bounded
  ])
  if (!product) throw createError({ status: 404, statusText: 'Not found' })
  return { product: toProductDto(product), reviews, related }
})
Gotcha· Edge rendering with a regional database

Moving SSR to the edge while every request makes four database round trips to Frankfurt makes pages slower for users in Frankfurt and barely faster elsewhere. Cache at the edge, batch queries, use an accelerator like Hyperdrive, or keep data-heavy routes regional and put only static and cached pages on the edge.

Gotcha· One tenant's cache for everyone

A cached handler keyed by URL on a multi-tenant host serves tenant A's featured products to tenant B. The tenant belongs in every cache key and every query, and a test that requests the same URL for two tenants belongs in the suite.

Verify before the interview:

Current routeRules options (swr, isr, cache.varies, appMiddleware), the status of experimental SSR streaming, and Hyperdrive's supported databases.

docs ↗

Exercise

Exercise
  • Write the rendering decision table for a product you know and implement it as routeRules in a playground; verify each row with curl -I.
  • Introduce a tenant middleware and a cached handler without the tenant in the key; request it for two hosts and observe the leak; fix the key.
  • Measure a page with a three-step client waterfall, then aggregate in a BFF route; compare LCP and the number of requests.

Be able to say

Be able to say· How do you decide the rendering and caching architecture for a site?

"Per route group, with five criteria: whether crawlers need it, whether it is personalised, how fresh it must be, how expensive it is to render and how interactive it is afterwards. Public and stable pages are prerendered and served by the CDN; public pages that change get swr or isr so they render once per interval; query-dependent pages render on the server without HTML caching but with cached functions underneath; personalised pages render privately or as an SPA behind auth. Caching is layered from the browser through the CDN and Nitro to the database, each with a key that includes everything that changes the output, tenant and locale included, and personalised data never shares a key. The server aggregates upstream calls into one payload so the client has no waterfalls, and I decide up front what to measure per route so the design can be checked against numbers."

Be able to say· Edge or regional deployment for a Nuxt app?

"The edge for what is static or cacheable and for request-light logic: prerendered pages, swr HTML, session validation, redirects, small BFF calls; users get low latency and it scales to zero. Regional Node servers for data-heavy routes that make several database queries, for long-lived connections and for CPU-heavy work, because the database lives in a region and every query from the edge pays a round trip. In practice a hybrid: the CDN and edge serve the cached half, the regional server handles the personalised half, and where edge rendering must touch the database, a connection accelerator such as Hyperdrive and read replicas reduce the penalty."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.