Building Nuxt apps

Rendering strategies

Universal rendering, SPA, prerendering, SWR and ISR per route, client-only and server components, islands, lazy hydration and streaming, and how to choose per route rather than per app.

"SSR or SSG?" is a junior question. The senior answer is "per route": a marketing page is prerendered, a product page is cached and revalidated, a dashboard is client-rendered behind auth, and inside each page some components render only on the server and others hydrate only when visible. Nuxt exposes all of that through routeRules and a handful of component conventions, and the interview tests whether you can map requirements to those knobs.

Know

  • Universal rendering (default): the server renders HTML for the request, the client hydrates. Best first paint and SEO; costs server CPU per request and needs SSR-safe code.
  • Client-side rendering (ssr: false globally, or per route): the server sends an empty shell (spaLoadingTemplate), the browser renders everything. Right for authenticated, personalised, non-indexed areas where server rendering buys nothing.
  • Prerendering (nuxt generate, or routeRules: { '/path': { prerender: true } }): HTML is produced at build time and served as static files. Nitro crawls links from the entry routes; nitro.prerender.routes and the prerender:routes hook add routes the crawler cannot discover. Payloads are extracted to _payload.json per page so client navigation between prerendered pages needs no server.
  • Hybrid rendering via routeRules: swr: seconds renders on demand, caches in Nitro's storage and serves stale while revalidating in the background; isr: seconds | true does the same through the platform's CDN cache on providers that support it (Vercel, Netlify); cache gives direct control over the Nitro cache entry; prerender, ssr: false, redirect, headers complete the set. A rule matches a path pattern, so the decision really is per route.
  • Client-only pieces: <ClientOnly> with a #fallback renders its children only after hydration; Component.client.vue does the same per component. Use them for browser-only widgets, not to hide hydration bugs.
  • Server components (Component.server.vue, experimental.componentIslands) render on the server and ship no JavaScript for the component; <NuxtIsland> renders one on demand. Interactive children can be marked nuxt-client when selectiveClient is on. Right for heavy static output: rendered Markdown, syntax highlighting, big tables.
  • Lazy hydration (Vue 3.5): <LazyX hydrate-on-visible | hydrate-on-idle | hydrate-on-interaction | hydrate-on-media-query | hydrate-after | hydrate-when | hydrate-never> renders on the server and defers attaching Vue until the strategy fires. The markup, SEO and LCP are unaffected; the hydration CPU moves off the critical path.
  • Streaming SSR (experimental in 4.5, off by default) flushes the shell before all data resolves. It helps TTFB on slow data but interacts with anything that assumes a complete document; treat it as something to test, not to enable blindly.
  • Choosing: ask four questions per route. Must crawlers or link previews read it? Is it personalised? How often does it change? How expensive is it to render? Public and stable → prerender; public and changing → swr/isr; personalised → universal with no caching, or client-only if not indexed; expensive and static → server components inside whatever mode the route uses.
  • Deployment follows the mode. Prerendered output is static hosting (this app, on Cloudflare's asset hosting); universal or hybrid needs a Nitro server preset (node-server, cloudflare_module, vercel, netlify, …). The fullstack section covers presets.

How it works

One app, four rendering strategies, decided in configuration:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/docs/**': { prerender: true },
    '/products/**': { swr: 300 }, // fresh enough for prices, one render per five minutes per URL
    '/blog/**': { isr: 3600 }, // CDN-level regeneration on platforms that support it
    '/account/**': { ssr: false }, // personalised, behind auth, not indexed
    '/api/**': { cors: true },
  },
  experimental: { componentIslands: { selectiveClient: true } },
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ['/sitemap.xml'], // not linked from any page
    },
  },
})

Routes the crawler cannot find, added from the data source at build:

server/plugins/prerender-routes.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('prerender:routes', async (routes) => {
    const slugs = await $fetch<string[]>('https://cms.example.com/api/product-slugs')
    for (const slug of slugs) routes.add(`/products/${slug}`)
  })
})

Inside a page, deciding what ships to the browser component by component:

app/pages/products/[slug].vue
<template>
  <article>
    <!-- Interactive above the fold: normal hydration -->
    <ProductGallery :images="product.gallery" />

    <!-- Heavy, static markup: server component, zero client JS. The button inside stays interactive. -->
    <ProductDescription :html="product.descriptionHtml">
      <AddToCart nuxt-client :product-id="product.id" />
    </ProductDescription>

    <!-- Below the fold, interactive: rendered now, hydrated when it scrolls into view -->
    <LazyProductReviews hydrate-on-visible :product-id="product.id" />

    <!-- Needs the browser: rendered only on the client with a placeholder in the server HTML -->
    <ClientOnly>
      <ProductArViewer :model="product.arModel" />
      <template #fallback>
        <div class="aspect-video animate-pulse bg-elevated" />
      </template>
    </ClientOnly>
  </article>
</template>

ProductDescription.server.vue is an ordinary SFC whose file name ends in .server.vue; Nuxt renders it on the server and never ships its script.

Gotcha· swr on a personalised route

swr caches the rendered HTML by URL. If the page shows the user's name, the first user's name is served to everyone for the cache duration: a data disclosure, not a performance win. Cache only what is identical for every visitor, and keep personalisation in client-side fetches or in routes without a cache rule.

Gotcha· ClientOnly as a hydration band-aid

Wrapping a component in <ClientOnly> to silence a mismatch warning also removes it from the server HTML, so it disappears for crawlers and appears late for users. Fix the cause (hydration section) unless the component genuinely cannot render without a browser.

Verify before the interview:

Which platforms support isr natively today, the current status of experimental SSR streaming, and whether componentIslands and selectiveClient are still behind the experimental flag.

docs ↗

Exercise

Exercise
  • Configure the routeRules above, build, run the Node server, and request /products/x three times with curl -i; observe the timing and the cache headers on the second and third response.
  • Convert a Markdown-rendering component to .server.vue and compare the page's client bundle with nuxt analyze before and after.
  • Add hydrate-on-visible to a below-the-fold component and record a Performance profile of page load; find where its hydration moved to.

Be able to say

Be able to say· How do you decide between SSR, SSG and client-side rendering?

"Per route, not per app, because Nuxt's route rules let me mix them. For each route I ask whether crawlers or link previews need it, whether it is personalised, how often it changes and how expensive it is to render. Public and stable pages are prerendered and served as static files. Public pages that change get swr or isr so they render once per interval instead of per request. Personalised pages render universally without caching, or client-side if they are behind auth and not indexed. Inside any page I then go component by component: server components for heavy static markup, lazy hydration for below-the-fold interactivity, ClientOnly for things that genuinely need a browser."

Be able to say· What is the difference between swr and isr in Nuxt route rules?

"Both serve a cached render and regenerate it in the background after the interval. swr is implemented by Nitro: the rendered response is stored in Nitro's cache storage and served from there, so it works on any host that runs the Nitro server, and the cache lives wherever that storage points. isr delegates to the platform: on Vercel or Netlify the CDN holds the page and revalidates it at the edge, which scales without hitting my server. On a platform without native ISR it falls back to behaving like swr. In both cases the cache key is the URL, so neither is safe for personalised content."

Your notes

Open in Notes

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