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.
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.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* 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.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).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.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.$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.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.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.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.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.Hybrid rendering is a config decision, not an architecture decision:
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:
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:
<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:
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'),
},
})
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.
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.
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.
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.useFetch with a raw $fetch in setup, watch the network tab during hydration, then switch back and compare.transform and pick to a useAsyncData and compare the size of #__NUXT_DATA__ in view-source before and after..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.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."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."
Vite, unplugin and build-time transforms
The Vite plugin hooks a module author uses, how addBuildPlugin and unplugin give one implementation for every builder, source-map-safe edits with magic-string, and dev-server hygiene.
Layers vs modules
The decision rule in one sentence, why a company toolkit is almost always both, and where the deep treatment lives.