4 min read
On this page

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 ref instead of useState for shared server state. Module-level refs leak state between SSR requests. Always use useState for any state that might be set during SSR.
  • Overly aggressive caching. Returning stale data from getCachedData without 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 transform to 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 default values. Without a default option, data.value is null before the fetch resolves and after errors. This causes template errors when accessing properties on null.
  • Polling without cleanup. setInterval for polling must be cleared in onUnmounted. Forgotten intervals cause memory leaks and unnecessary network traffic after the user leaves the page.

Key Takeaways

  • useState is the SSR-safe alternative to ref for shared state. It is scoped per request on the server and behaves like a normal ref on the client.
  • getCachedData in useFetch controls when to serve cached data versus re-fetching. Returning undefined triggers 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 transform to keep it small by stripping unnecessary fields.
  • Error handling strategies include per-request error refs, try/catch in useAsyncData, and default values for graceful degradation.
  • Always clean up polling intervals and event listeners in onUnmounted to prevent leaks.