Caching & State
Data fetching is only half the story. Once data arrives, you need to decide how long it stays fresh, where it lives, and how to transfer it from server to client during SSR. Nuxt 3 provides useState for SSR-safe reactive state, getCachedData for controlling cache behavior in useFetch, and the payload system for hydrating server-fetched data on the client.
useState: SSR-Safe Shared State
Vue's ref and reactive work on the client, but they cause state leakage on the server. Because the server processes multiple requests in the same Node.js process, a module-level ref is shared across all users. useState solves this by scoping state to the current request on the server and behaving like a normal ref on the client.
<script setup lang="ts">
// Safe for SSR -- scoped per request on server
const count = useState('counter', () => 0)
function increment() {
count.value++
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
Shared State Across Components
useState with the same key returns the same ref across components, making it a lightweight alternative to Pinia for simple cases:
// composables/useAuth.ts
export function useAuth() {
const user = useState<User | null>('auth-user', () => null)
const isLoggedIn = computed(() => user.value !== null)
async function login(email: string, password: string) {
const result = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
user.value = result.user
}
function logout() {
user.value = null
navigateTo('/login')
}
return { user, isLoggedIn, login, logout }
}
<!-- components/NavBar.vue -->
<script setup lang="ts">
const { user, isLoggedIn, logout } = useAuth()
</script>
<template>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<template v-if="isLoggedIn">
<span>{{ user?.name }}</span>
<button @click="logout">Logout</button>
</template>
<NuxtLink v-else to="/login">Login</NuxtLink>
</nav>
</template>
<!-- pages/login.vue -->
<script setup lang="ts">
const { login } = useAuth()
// Both components share the same user state via useState('auth-user')
</script>
Why Not a Module-Level ref?
// DANGEROUS on the server -- state leaks between requests
const globalCount = ref(0)
export function useCounter() {
return { count: globalCount }
}
On the server, globalCount persists across all incoming requests. User A increments it, and user B sees the incremented value. useState is request-scoped on the server, preventing this leakage entirely.
Caching with getCachedData
useFetch can use cached data instead of re-fetching on every navigation. The getCachedData option lets you define when to serve cached data and when to fetch fresh data.
<script setup lang="ts">
const { data: config } = await useFetch('/api/site-config', {
getCachedData(key, nuxtApp) {
return nuxtApp.payload.data[key] || nuxtApp.static.data[key]
}
})
</script>
Time-Based Cache
A common pattern caches data for a fixed duration:
<script setup lang="ts">
const { data: exchangeRates } = await useFetch('/api/exchange-rates', {
getCachedData(key, nuxtApp) {
const cached = nuxtApp.payload.data[key]
if (!cached) return undefined
const fetchedAt = nuxtApp.payload._fetchTimestamps?.[key]
if (!fetchedAt) return undefined
const maxAge = 5 * 60 * 1000 // 5 minutes
const isStale = Date.now() - fetchedAt > maxAge
return isStale ? undefined : cached
}
})
</script>
Returning undefined from getCachedData tells Nuxt to fetch fresh data. Returning a value uses that value and skips the network request.
Cache Until Data Changes
For data that changes infrequently, combine caching with a manual refresh trigger:
<script setup lang="ts">
const { data: categories, refresh } = await useFetch('/api/categories', {
getCachedData(key, nuxtApp) {
return nuxtApp.payload.data[key]
}
})
// Refresh only when the user explicitly requests it
// or after a mutation that affects categories
async function addCategory(name: string) {
await $fetch('/api/categories', {
method: 'POST',
body: { name }
})
await refresh()
}
</script>
Data Freshness: Controlling When to Refetch
Beyond getCachedData, several strategies control when data is re-fetched.
Watching Reactive Dependencies
<script setup lang="ts">
const selectedRegion = ref('us-east')
const { data: servers } = await useFetch('/api/servers', {
query: { region: selectedRegion }
// Automatically re-fetches when selectedRegion changes
})
</script>
Interval Polling
For data that needs periodic updates without user interaction:
<script setup lang="ts">
const { data: metrics, refresh } = await useFetch('/api/metrics')
// Poll every 30 seconds
const interval = ref<ReturnType<typeof setInterval>>()
onMounted(() => {
interval.value = setInterval(() => {
refresh()
}, 30_000)
})
onUnmounted(() => {
clearInterval(interval.value)
})
</script>
Refresh on Window Focus
Re-fetch when the user returns to the tab:
<script setup lang="ts">
const { data: notifications, refresh } = await useFetch('/api/notifications')
onMounted(() => {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
refresh()
}
})
})
</script>
Optimistic Updates
For mutations where you want the UI to update immediately without waiting for the server:
<script setup lang="ts">
interface Todo {
id: number
title: string
completed: boolean
}
const { data: todos, refresh } = await useFetch<Todo[]>('/api/todos')
async function toggleTodo(todo: Todo) {
// Optimistic: update the UI immediately
const previousState = todo.completed
todo.completed = !todo.completed
try {
await $fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: { completed: todo.completed }
})
} catch {
// Rollback on failure
todo.completed = previousState
alert('Failed to update. Please try again.')
}
}
</script>
<template>
<ul>
<li v-for="todo in todos" :key="todo.id">
<label>
<input
type="checkbox"
:checked="todo.completed"
@change="toggleTodo(todo)"
/>
{{ todo.title }}
</label>
</li>
</ul>
</template>
The UI responds instantly. If the server rejects the change, the previous state is restored.
Error Handling in Data Fetching
Per-Request Error Handling
<script setup lang="ts">
const { data, error, status, refresh } = await useFetch('/api/dashboard')
</script>
<template>
<div>
<div v-if="error" class="error-banner">
<p>Failed to load dashboard: {{ error.message }}</p>
<button @click="refresh">Retry</button>
</div>
<div v-else-if="status === 'pending'">
<LoadingSkeleton />
</div>
<DashboardContent v-else :data="data" />
</div>
</template>
Global Error Handling in useAsyncData
<script setup lang="ts">
const { data } = await useAsyncData('critical-data', async () => {
try {
return await $fetch('/api/critical-endpoint')
} catch (err) {
// Log to error tracking service
reportError(err)
// Return fallback data instead of crashing the page
return { items: [], message: 'Using cached data' }
}
})
</script>
Default Values for Failed Fetches
<script setup lang="ts">
const { data: settings } = await useFetch('/api/user/settings', {
default: () => ({
theme: 'light',
language: 'en',
notifications: true
})
})
// settings.value is never null -- defaults apply if fetch fails
</script>
The Payload: Server-to-Client Data Transfer
During SSR, Nuxt serializes all data fetched by useFetch and useAsyncData into a JavaScript object embedded in the HTML. This is the payload.
Server renders the page:
1. useFetch('/api/products') runs on the server
2. Response data is stored in nuxtApp.payload.data['products']
3. HTML is rendered with the data
4. Payload is serialized as: <script>window.__NUXT__ = { data: { ... } }</script>
Client hydrates:
1. Vue mounts the app
2. useFetch checks the payload before making a network request
3. Finds the data in payload -> uses it, skips the fetch
4. Component is interactive with server-fetched data
Inspecting the Payload
During development, you can inspect the payload in the browser:
// In browser console
console.log(useNuxtApp().payload.data)
Reducing Payload Size
Large payloads slow down initial page load. Use transform to strip unnecessary fields:
<script setup lang="ts">
const { data: users } = await useFetch('/api/users', {
transform: (users) => users.map(({ id, name, avatar }) => ({ id, name, avatar }))
// Internal fields like passwordHash, email, etc. are excluded from the payload
})
</script>
Custom Payload Serialization
For types that JSON cannot serialize (Date objects, Map, Set), use payload reducers:
// plugins/payload.ts
export default defineNuxtPlugin((nuxtApp) => {
const reducers: Record<string, (val: any) => any> = {
Date: (val) => val instanceof Date && val.toISOString()
}
const revivers: Record<string, (val: any) => any> = {
Date: (val) => new Date(val)
}
nuxtApp.hook('app:rendered', () => {
nuxtApp.payload.types = reducers
})
nuxtApp.hook('app:created', () => {
for (const [type, reviver] of Object.entries(revivers)) {
definePayloadReviver(type, reviver)
}
})
})
Common Pitfalls
- Using
refinstead ofuseStatefor shared server state. Module-level refs leak state between SSR requests. Always useuseStatefor any state that might be set during SSR. - Overly aggressive caching. Returning stale data from
getCachedDatawithout any expiration means users never see updates. Always pair caching with a freshness strategy (TTL, manual refresh, or event-based invalidation). - Giant payloads. Fetching entire database tables and serializing them into the HTML payload bloats the initial page load. Use
transformto trim data and paginate large collections. - Not handling optimistic update failures. Optimistic updates that skip rollback logic leave the UI in an inconsistent state when the server rejects the mutation.
- Forgetting
defaultvalues. Without adefaultoption,data.valueisnullbefore the fetch resolves and after errors. This causes template errors when accessing properties on null. - Polling without cleanup.
setIntervalfor polling must be cleared inonUnmounted. Forgotten intervals cause memory leaks and unnecessary network traffic after the user leaves the page.
Key Takeaways
useStateis the SSR-safe alternative toreffor shared state. It is scoped per request on the server and behaves like a normal ref on the client.getCachedDatainuseFetchcontrols when to serve cached data versus re-fetching. Returningundefinedtriggers a fresh fetch; returning data skips it.- Optimistic updates immediately reflect mutations in the UI and roll back if the server rejects the change.
- The payload is the serialized data blob transferred from server to client during SSR. Use
transformto keep it small by stripping unnecessary fields. - Error handling strategies include per-request
errorrefs, try/catch inuseAsyncData, anddefaultvalues for graceful degradation. - Always clean up polling intervals and event listeners in
onUnmountedto prevent leaks.