skill · sumi-nuxt · v0.6.1
Nuxt Data, SSR and Performance
Data fetching, SSR and hydration safety, and delivery performance in Nuxt 4 - useFetch/useAsyncData keys and options, $fetch vs useFetch, hydration mismatch prevention (client-only APIs, dates, randomness, locale), payload size, useState, caching, lazy loading and lazy hydration, SEO with useSeoMeta, @nuxt/image, @nuxt/fonts and Core Web Vitals budgets. Use when a *.vue page or composable loads data, when you see hydration mismatch warnings, slow LCP/INP/CLS, large _payload, or when touching SEO meta, images or fonts.
- Reference
- sumi-nuxt
- skill
- nuxt-data-ssr
- source
- plugins/sumi-nuxt/skills/nuxt-data-ssr/SKILL.md
SSR renders HTML on the server, serializes the data into a payload, and the client hydrates the same tree. Every rule here protects one of three things: the server and client render identical HTML, data is fetched once (not twice), and the user gets meaningful pixels fast.
Core principles
- Fetch in setup with
useFetch/useAsyncData, so data is fetched on the server and transferred in the payload. Raw$fetchin setup double-fetches (server, then client again). - Every async data call has a deliberate key. Same key = shared, deduplicated data across components (Nuxt 4 singleton data layer). Different data must never share a key.
- Ship only what the page renders. Trim with
pick/transformor a dedicated server endpoint. - Render deterministic markup. No time, randomness, locale or browser state in SSR output.
- Measure against budgets, not vibes: LCP < 2.5s, INP < 200ms, CLS < 0.1 on a mid-range mobile
(
sumi:performance).
Choosing the primitive
| Need | Use |
|---|---|
| Data needed for initial render, from your API | useFetch('/api/x') |
| Data from an SDK/function (Firestore, CMS client) | useAsyncData('key', () => fn()) |
| User-triggered action (submit, delete, load more) | $fetch in an event handler |
| Non-critical data (below fold, recommendations) | lazy: true (+ server: false if not SEO-relevant) |
| Run something once per request across components | callOnce (verify behavior for your version) |
| Request-scoped shared value | useState('key', init) |
// GOOD: typed, keyed, trimmed, with explicit states
const route = useRoute()
const { data: project, status, error } = await useFetch(`/api/projects/${route.params.id}`, {
pick: ['id', 'name', 'status', 'updatedAt'],
})
if (error.value?.statusCode === 404) throw createError({ statusCode: 404, fatal: true })
// BAD: double fetch, untyped, no error state
const project = ref<any>(null)
onMounted(async () => { project.value = await $fetch(`/api/projects/${id}`) })
Key and option rules
useFetchderives a key from URL + options. WithuseAsyncData, always pass an explicit key that encodes every input:`project:${id}`,`projects:${page}:${sort}`.- Reactive params: pass refs/getters in
query/URL so it refetches automatically. Do not add awatchthat callsrefresh()for the same thing. - Nuxt 4 defaults:
datais ashallowRef(setdeep: trueonly if you mutate nested fields), and the empty value isundefined. Usedefault: () => []for list shapes. - Use
status(idle | pending | success | error) for UI states, not ad-hocloadingrefs. getCachedDatacontrols reuse of payload/static data across navigations; use it to avoid refetching stable data on client navigation.refreshNuxtData(key)/clearNuxtData(key)to invalidate.- Never
awaitmultiple independentuseFetchcalls in sequence when they can run in parallel; combine withPromise.allinside oneuseAsyncData, or make the secondary oneslazy.
Hydration mismatches
Root causes and fixes (full catalog in references/hydration.md):
| Cause | Fix |
|---|---|
window, localStorage, matchMedia read during setup |
Read in onMounted, or wrap in <ClientOnly> with a same-size fallback |
new Date(), Date.now(), relative time ("3 min ago") |
Format from a server-provided timestamp with a fixed timezone; render relative time after mount |
Math.random(), crypto.randomUUID() for ids |
useId() |
toLocaleString() without explicit locale/timezone |
Intl.DateTimeFormat('es-CO', { timeZone: 'America/Bogota' }) with explicit args |
| Auth-dependent markup when server has no session | Forward auth (cookie) to the server, or render that region client-only with a skeleton |
Invalid HTML nesting (<div> in <p>, <a> in <a>) |
Fix the markup; the browser rewrites it and Vue sees a mismatch |
| Theme/dark mode from localStorage | Cookie-based preference or @nuxtjs/color-mode, which injects a pre-hydration script |
Never silence mismatches with <ClientOnly> around large page sections; that throws away SSR for
SEO and LCP. Use it for small, genuinely client-only islands and give it a #fallback of equal size
to avoid CLS.
Payload and bundle size
- Inspect
/_payload.jsonorwindow.__NUXT__on heavy pages; if it is larger than the HTML that renders it, you are shipping unused data. Usepick,transform, or a purpose-built endpoint. - Never put secrets or internal fields in data returned to
useFetch; payload is public. - Non-serializable values (class instances, Firestore
Timestamp,Map) need conversion intransformor a custom payload reducer. - Code-split heavy components with
Lazy*+v-if; consider lazy hydration (hydrate-on-visible,hydrate-on-interaction) for below-fold interactive islands (verify for your Nuxt version). - Third-party scripts via
@nuxt/scriptsor deferred loading; never synchronous in<head>. - Analyze with
nuxi analyze. Every dependency over ~20 kB gzipped needs a reason.
Caching layers
- routeRules (
prerender,isr,swr,cache) for whole responses at the edge/server. defineCachedEventHandler/cachedFunctionin Nitro for expensive upstream calls.- Payload reuse on the client via
getCachedDatafor navigation. - HTTP cache headers on static assets (Nuxt hashes them; keep immutable caching). Pick the outermost layer that is correct for the data's freshness needs. Authenticated, per-user data is never cached at a shared layer.
SEO and meta
useSeoMeta({
title: () => project.value?.name ?? 'Project',
description: () => project.value?.summary,
ogTitle: () => project.value?.name,
ogImage: () => project.value?.coverUrl,
twitterCard: 'summary_large_image',
})
useSeoMeta(typed) over rawuseHead({ meta }). Use getters for reactive values.- Set
app.head.htmlAttrs.langand a title template once. Canonical URLs viauseHead({ link }). - Correct status codes for missing content (
createError404) so pages are not soft-404s. - Structured data (JSON-LD) only when it reflects visible content; consider
nuxt-schema-org.
Images and fonts
@nuxt/image:<NuxtImg>/<NuxtPicture>withwidth/height(or aspect-ratio) to prevent CLS,sizesfor responsive srcset,<NuxtPicture format="avif,webp">when you need multiple formats (<NuxtImg>takes a singleformat).- The LCP image:
loading="eager",fetchpriority="high",preload; never lazy-load it. Everything below the fold:loading="lazy". @nuxt/fonts: self-hosted, subsetted,font-display: swap, with metric-matched fallbacks to reduce CLS. Limit families and weights (two families, three or four weights total is a reasonable cap).- Font and image choices come from
DESIGN.mdtokens; do not introduce new families ad hoc.
Anti-patterns (AI slop tells)
onMounted+$fetch+reffor initial page data (ReactuseEffecthabit).useAsyncDatawithout a key, or two different queries sharing one key.- A
watchon route params callingrefresh()when the URL is already reactive. <ClientOnly>around the whole page to "fix hydration".new Date().toLocaleDateString()in templates.- Returning full Firestore documents (with internal fields) to the client payload.
- Sequential awaits of independent requests causing waterfalls.
- Hero image with
loading="lazy", no dimensions, or a 3000px JPEG. - Importing a whole utility library (
lodash,moment) for one function. ssr: falseglobally to avoid thinking about hydration.
Done checklist
- Initial-render data uses
useFetch/useAsyncData; actions use$fetch. - Every async call has a unique, input-complete key; UI handles
pending,error, empty. - No hydration warnings in the browser console on hard reload of every touched page.
- Payload contains only rendered fields; no secrets; non-serializable values transformed.
- LCP image is eager/high priority with dimensions; other images lazy; fonts via
@nuxt/fonts. -
useSeoMetaset; correct 404 status for missing entities. - Caching layer chosen per route; no shared caching of per-user data.
- Lighthouse/Web Vitals on mobile profile meet budgets (
sumi:performance).
Hydration mismatch catalog and fixes
How to diagnose
- Hard reload the page with devtools console open. Vue logs the mismatching node in dev.
- Compare View Source (server HTML) with the Elements panel right after load.
- Bisect: wrap suspect subtrees in
<ClientOnly>temporarily to locate the culprit, then fix the cause and remove the wrapper. - Check for browser extensions injecting markup (test in a clean profile) before blaming code.
Patterns
Browser-only APIs
// BAD: crashes on server or renders different branch
const isMobile = window.innerWidth < 768
// GOOD: server and first client render agree (false), then update after mount
const isMobile = ref(false)
onMounted(() => {
const mq = window.matchMedia('(max-width: 767px)')
isMobile.value = mq.matches
mq.addEventListener('change', (e) => { isMobile.value = e.matches })
})
Better still: solve layout differences with CSS (media/container queries) so there is no JS branch.
Dates and time zones
The server likely runs in UTC; the user's browser does not. Formatting without an explicit time zone produces different strings.
const fmt = new Intl.DateTimeFormat('es-CO', {
dateStyle: 'medium', timeZone: 'America/Bogota',
})
const label = computed(() => fmt.format(new Date(event.value.startsAt)))
For "time ago" labels: render the absolute date on the server inside <time datetime="...">, then
swap to relative text in onMounted. Or render relative text client-only with a fixed-width fallback.
User-specific content
If the server cannot see the session, it renders the logged-out view and the client renders the logged-in view. Options:
- Session cookie readable on the server (
useCookie, or Firebase session cookies verified in a Nitro middleware) so both sides agree. - Route is
ssr: false(dashboards). - Small auth-dependent island (
<ClientOnly>) with a skeleton of identical dimensions.
Persisted UI state (theme, dismissed banners)
localStorage is invisible to the server. Use a cookie (useCookie('theme')) so SSR renders the
correct variant, or a module that injects a blocking inline script before hydration
(@nuxtjs/color-mode).
Invalid HTML
The browser's parser silently fixes invalid nesting, so its DOM differs from Vue's virtual tree.
Common offenders: block elements inside <p>, interactive inside interactive (<button> in <a>),
<tr> directly inside <table> without <tbody> in some cases, <li> outside lists.
Run the HTML validator on rendered output when in doubt.
Non-deterministic ordering
Object.keys on data assembled in different order, Set iteration of async-filled data, or
sort() with an unstable comparator. Sort deterministically on the server and transfer the result.
Data fetching pitfalls that look like hydration bugs
useAsyncDatawithserver: falsereturns no data during hydration; the first client render must match the server's empty/pending state. Render a skeleton forpending.- Key collision: two components using the same key for different data get each other's payload.
- Mutating the payload data object in place on the client before mount.
Firestore data in the payload
Timestamp, DocumentReference and GeoPoint are not plain JSON. Convert in transform:
const { data } = await useAsyncData(`post:${slug}`, () => getPost(slug), {
transform: (p) => p && ({ ...p, publishedAt: p.publishedAt.toMillis() }),
})
Or convert at the repository boundary so components never see SDK types.