Under the hood

Rendering modes and data

Universal, SPA, hybrid and static rendering; islands and lazy hydration; the real option list for useAsyncData and useFetch; the payload, plugins and unhead.

This is where internals become visible to users: rendering mode decides what the first byte contains, data fetching decides whether the client re-does the server's work, and the payload is the wire between them. Interviewers like the area because the wrong answer is usually plausible — "I'll just call $fetch in setup" sounds fine and costs two requests and a hydration mismatch.

Know

  • Four modes, one config. Universal SSR (default) renders on the server and hydrates. SPA is ssr: false — an empty shell plus client rendering. Hybrid is per route via routeRules, mixing prerendered, ISR, SWR, cached and client-only routes in one deployment. Static is nuxt generate, prerendering every route the crawler finds plus nitro.prerender.routes.
  • Server components keep code off the client. A *.server.vue component (or <NuxtIsland> directly) renders on the server and ships HTML, not JavaScript — the component and its dependencies never enter the client bundle. Interactive children inside an island are opted back in with the nuxt-client attribute.
  • Lazy hydration is per component, declared in the template. On any Lazy* component: hydrate-on-visible, hydrate-on-idle, hydrate-on-interaction, hydrate-on-media-query, hydrate-after (a delay), hydrate-when (a condition) and hydrate-never. The server HTML is always rendered; only hydration is deferred, so it buys interactivity without costing SEO or LCP.
  • useAsyncData / useFetch exist to stop the client refetching. They run the handler during SSR, serialise the result into the payload, and the client reuses it instead of fetching again. useFetch is useAsyncData plus $fetch with an auto-generated key.
  • The option list worth knowing cold: key, lazy, server, immediate, watch, pick, transform, default, getCachedData(key, nuxtApp, ctx) where ctx.cause is 'initial' | 'refresh:manual' | 'refresh:hook' | 'watch', dedupe: 'cancel' | 'defer' (default 'cancel'), and deep (default false, so the returned data is a shallow ref).
  • What it returns: data (undefined until resolved, not null), error, status'idle' | 'pending' | 'success' | 'error' — plus pending, refresh, execute and clear. Prefer status over pending: it distinguishes "not started" from "in flight", which is what most loading UIs actually need.
  • Calls sharing a key must use consistent options. Two components calling useAsyncData('user', …) with different transform or pick share one cache entry, and the second one to run does not necessarily win. Wrap shared fetches in a composable so the key and its options live in exactly one place.
  • transform and pick shrink the payload, because they run before serialisation. Fetching a 200 KB object to render three fields puts 200 KB in the HTML of every SSR response unless you pick.
  • A raw $fetch in setup double-fetches, because nothing put its result in the payload: it runs during SSR and again during hydration. Two requests, a slower page, and a mismatch window if the responses differ.
  • SSR-safe state: useState(key, init) is a payload-serialised ref — the fix for a module-scope ref that would be shared by every SSR request in the process. callOnce(fn, { mode: 'navigation' }) runs logic once per request rather than on every navigation. useCookie reads the request header on the server and document.cookie on the client, so both renders agree.
  • The payload is serialised with devalue, so Date, Map, Set, RegExp and BigInt survive; your own classes need definePayloadReducer / definePayloadReviver in a plugin. Read it at runtime with useNuxtApp().payload; prerendered pages get a separate _payload.json.
  • Plugins use the object syntax: defineNuxtPlugin({ name, enforce, parallel, dependsOn, setup, hooks, env }), with .client / .server suffixes for side-specific ones. They run on every SSR request and once per session on the client, so prefer a composable unless you need app-wide setup or a provide.
  • Head is unhead: useHead for raw control, useSeoMeta for a flat, typed meta API, useHeadSafe for anything derived from user input. Tags are deduplicated and ordered for performance rather than emitted in call order.

How it works

Hybrid rendering is a config decision, not an architecture decision:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true }, // built once at deploy
    '/blog/**': { isr: 3600 }, // cached at the edge, revalidated hourly
    '/pricing': { swr: 600 }, // stale-while-revalidate on the server
    '/app/**': { ssr: false }, // client-only shell for the logged-in area
  },
})

The data layer, with the options that matter for a shared composable:

app/composables/useTeam.ts
export function useTeam(id: Ref<string>) {
  return useAsyncData(
    `team:${id.value}`,
    () => $fetch<TeamDto>(`/api/teams/${id.value}`),
    {
      watch: [id],
      dedupe: 'cancel', // default: a new request aborts the in-flight one
      // shrink the payload before it is serialised into the HTML
      transform: team => ({ id: team.id, name: team.name, memberCount: team.members.length }),
      default: () => ({ id: '', name: '', memberCount: 0 }),
      // reuse what is already in the payload on the initial client render,
      // but always go to the network for an explicit refresh
      getCachedData(key, nuxtApp, ctx) {
        if (ctx.cause === 'refresh:manual') return undefined
        return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
      },
    },
  )
}

Islands and lazy hydration, side by side:

app/pages/article.vue
<template>
  <!-- rendered on the server; neither the component nor markdown-it reaches the client -->
  <ArticleBody :html="html" />

  <!-- HTML is server-rendered; hydration waits until it scrolls into view -->
  <LazyCommentThread :article-id="id" hydrate-on-visible />

  <!-- never hydrated: static output, zero JS -->
  <LazyLegalFooter hydrate-never />
</template>

A plugin that orders itself and teaches the payload a custom class:

src/runtime/plugins/toolkit.ts
import { defineNuxtPlugin, definePayloadReducer, definePayloadReviver } from '#imports'

export default defineNuxtPlugin({
  name: 'acme:toolkit',
  enforce: 'pre',
  parallel: true,
  dependsOn: ['acme:auth'], // this plugin waits for the auth plugin
  env: { islands: false }, // skip it when rendering islands
  setup(nuxtApp) {
    definePayloadReducer('Money', data => data instanceof Money && [data.amount, data.currency])
    definePayloadReviver('Money', ([amount, currency]) => new Money(amount, currency))
    return { provide: { toolkit: createToolkit(nuxtApp) } }
  },
  hooks: {
    'app:mounted': () => performance.mark('toolkit:ready'),
  },
})
Gotcha· Two useAsyncData calls, one key, different options

useAsyncData('user', fetchUser, { pick: ['name'] }) in a header and useAsyncData('user', fetchUser) in a profile page share one cache entry. Whichever resolves first defines what the other sees, so the profile page intermittently renders a user object with only name — and it reproduces on one machine in three. Nuxt warns about conflicting options in dev, but the reliable fix is structural: one exported composable owns the key and the options, and every consumer calls that.

Gotcha· A module-scope ref is shared by every SSR request

const user = ref(null) at the top of a composable file is created once per server process, not once per request. Request A's user leaks into request B's render. Everything request-scoped goes through useState (app side) or event.context (server side); the symptom — one user seeing another's name for a moment under load — is the hardest kind of bug to reproduce locally, where you are the only request.

Verify before the interview:

Two things to re-check: whether selective client hydration inside islands (nuxt-client) still requires experimental.componentIslands.selectiveClient, and the current status of experimental.payloadExtraction for prerendered _payload.json. Also skim the useAsyncData reference for any option added since 4.5.

docs ↗

Exercise

Exercise
  • Build a fixture with one prerender: true route, one swr: 60 route and one ssr: false route. Inspect .output/public for the prerendered HTML and curl -I the SWR route twice to see the cache headers change.
  • Replace a useFetch with a raw $fetch in setup, watch the network tab during hydration, then switch back and compare.
  • Add transform and pick to a useAsyncData and compare the size of #__NUXT_DATA__ in view-source before and after.
  • Ship a .server.vue island from your module plus a Lazy component with hydrate-on-visible, and confirm in the client bundle that the island's code is absent.
  • Return a Date and a custom class from an API route; confirm the Date survives the payload and the class does not, then fix it with definePayloadReducer / definePayloadReviver.

Be able to say

Be able to say· Why use useAsyncData instead of calling $fetch in setup?

"useAsyncData is the bridge between the server render and the client. It runs the handler during SSR, serialises the result into the payload, and the client's first render reuses that value instead of fetching again — so one request, no loading flash and no hydration mismatch. A raw $fetch in setup runs on both sides: two requests, a slower page, and a window where the two responses differ and the client patches the DOM. The options I care about are key — because everything keys off it and calls sharing a key must share options — plus transform and pick to shrink what goes over the wire, getCachedData with its ctx.cause to decide when a refresh really means the network, and dedupe. On the state side the same logic applies: useState rather than a module-scope ref, because a module-scope ref is shared by every SSR request the process handles."