Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 038 / 100

State in Nuxt: what every Vue & Nuxt developer should know

Share state safely with SSR.

Expona Admin8 min read

Introduction

State in Nuxt: what every Vue & Nuxt developer should know—because managing shared data across server and client renders differently than you might expect. When you're building with Nuxt, the rules of state management shift under your feet the moment you add server-side rendering into the mix. We've seen teams burn hours debugging issues that came down to a single misunderstanding about when and where state gets created, accessed, and transferred between the server and browser.

The problem gets worse as your application grows. What works fine in a client-only SPA falls apart under SSR. Module-level state leaks between requests. Store instances don't serialize properly. Payload data goes missing. We'll walk through the patterns that actually work, the traps to avoid, and how to think about state differently in a Nuxt world.

useState for SSR-safe shared state

The useState composable is Nuxt's answer to SSR-safe state that lives in components. Unlike a plain ref created at the module level, useState creates state that's isolated per request on the server and automatically hydrated on the client.

Here's the practical difference. On the server, every incoming request needs its own independent state tree. If you create a ref outside a component, all requests share that same object. Disaster. On the client, that ref persists across route changes. useState handles both scenarios by using a key-based system.

typescript
// composables/useCart.ts
export const useCart = () => {
  const items = useState('cart', () => [])
  const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))

  const addItem = (item: CartItem) => {
    items.value.push(item)
  }

  const clearCart = () => {
    items.value = []
  }

  return { items, total, addItem, clearCart }
}

The key ('cart' in this example) is what ensures Nuxt can find the right state tree across server and client. When the server renders, it stores that state with that key. When the page hydrates in the browser, Nuxt looks up state by key and binds it to the same composable instance. Call useCart() from different components, and they all reference the same state object.

On the server side, each HTTP request gets its own sandbox where useState creates fresh state instances. That's how two simultaneous users don't see each other's shopping carts. When the HTML payload ships to the browser, that initial state comes along embedded in the page. JavaScript takes over, and the same composable calls now pull from the hydrated state instead of creating new instances.

This works for any serializable data: strings, numbers, objects, arrays. It doesn't work for classes, functions, or things like Date objects that need special handling. If you need to store something complex, serialize it first.

Pinia module for stores

When your state needs to be more than a few related values, Pinia stores are the structured approach. A Pinia store is just a composable that organizes state, getters, and actions. In Nuxt, you define them in the stores/ directory and Nuxt auto-imports them.

typescript
// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const user = ref(null)
  const isLoading = ref(false)
  const error = ref(null)

  const fetchUser = async (id: string) => {
    isLoading.value = true
    error.value = null
    try {
      const data = await $fetch(`/api/users/${id}`)
      user.value = data
    } catch (e) {
      error.value = e.message
    } finally {
      isLoading.value = false
    }
  }

  const logout = () => {
    user.value = null
  }

  return { user, isLoading, error, fetchUser, logout }
})

Use this store on the server during SSR by calling it in definePageMeta or a middleware. The store instance gets created fresh for each request because Nuxt wraps the entire request in a composable context.

typescript
// pages/dashboard.vue
export default definePageMeta({
  middleware: async (context) => {
    const userStore = useUserStore()
    if (!userStore.user) {
      await userStore.fetchUser(context.params.id)
    }
  }
})

definePageMeta({
  layout: 'dashboard'
})

The key insight: Pinia stores work with useState under the hood. When you define a store with the composition API (using ref and computed), those refs are automatically wrapped in useState calls internally. Nuxt sees them, serializes their values into the payload, and hydrates them on the client.

That means a Pinia store is SSR-safe by default as long as you follow the composition API pattern. The old options API syntax for stores doesn't work well with SSR because it has different initialization logic.

Never use module-level state on the server

This is where we see the biggest mistakes. A ref created outside any function, at the module level, is shared across all server requests. Every user mutation pollutes the shared object.

typescript
// composables/useBadCart.ts - DO NOT DO THIS
const items = ref([])  // ❌ Shared across all requests

export const useBadCart = () => {
  const addItem = (item: CartItem) => {
    items.value.push(item)
  }

  return { items, addItem }
}

If two users load the page simultaneously on the server, they share the same items array. User A adds a shirt. User B sees it. This is a security and data integrity nightmare.

The correct pattern wraps the ref inside a function, so each call creates a fresh instance. useState does this for you automatically.

typescript
// composables/useGoodCart.ts - DO THIS
export const useGoodCart = () => {
  const items = useState('cart', () => [])  // ✓ Fresh per request

  const addItem = (item: CartItem) => {
    items.value.push(item)
  }

  return { items, addItem }
}

Same composable name, different behavior. useState creates isolated state on the server while maintaining cross-request shared state on the client (within the same page session).

If you're writing a utility that needs to track something internally without exposing it to the template, still avoid module-level state. Use a composable or a class that gets instantiated fresh.

Payload transfers state to the client

The payload is the JSON blob Nuxt embeds in your HTML page that contains all the initial state. It looks like a script tag near the closing body:

typescript
<script>__NUXT_PAYLOAD__={...}</script>

Everything you set via useState or a Pinia store gets included in that payload automatically, as long as it's serializable. On the client, Nuxt parses the payload and hydrates all that state before mounting components. Your refs wake up with the exact values they had on the server.

This is powerful because your page doesn't need to re-fetch data after hydration. The user sees the full rendered HTML from the server, then JavaScript takes over with state already in place. No loading spinners, no flash of empty content.

Sometimes you have state that shouldn't serialize into the payload. Authentication tokens, for example, or WebSocket connections. Use definePayloadReducer or keep that data in a separate, non-state location (like a service instantiated only on the client).

typescript
// server/utils/auth.ts
let sessionToken: string | null = null

export const setSessionToken = (token: string) => {
  sessionToken = token
}

export const getSessionToken = () => sessionToken

This runs only on the server. The token stays in memory and never appears in the HTML. On the client, if you need to send the token with requests, pass it from a cookie or read it from a meta tag you've inserted separately.

The payload is also a good place to optimize. Large initial state bloats your HTML. If you have a store with data that the client doesn't immediately need, consider lazy-loading it or keeping it in a separate fetched endpoint rather than embedding it in the page.

Common mistakes

  • Forgetting the key in useState: useState(() => []) without a key will create a new state instance every time the composable runs. Always provide a unique string key.
  • Mixing component state with server state: A ref in a component's setup is fine for local UI state (like a modal's open/closed toggle), but don't use it to share data across pages or requests. Use useState instead.
  • Calling store methods inside templates: If your store has an async action like fetchUser(), call it in middleware or a lifecycle hook, not in the template. SSR can't wait for promises in templates.
  • Assuming client-only patterns work on the server: localStorage, window, and DOM APIs don't exist on the server. Wrap them in if (process.client) or use a composable that checks process.server.
  • Storing non-serializable data in state: Class instances, functions, and Dates won't survive serialization. Convert them to plain objects before storing.

Key takeaways

  • useState with a key creates isolated state per server request and shared state per client session.
  • Pinia stores using the composition API are SSR-safe because they use useState internally.
  • Module-level state (refs outside functions) is shared across all server requests—never use it for user-specific data.
  • The payload automatically serializes all state created via useState and Pinia and hydrates it in the browser.
  • Keep sensitive data and non-serializable types out of state; handle them separately on the server or client.
  • Always call async store actions in middleware or lifecycle hooks, never in templates or directly during render.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles