Stop Using ref() in Nuxt: Why useState is Critical for SSR

A common Nuxt SSR bug explained: why ref() can leak data between users, how useState prevents it, and when to use each to avoid hidden production issues.

Michael Thiessen
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view course

You've got a weird bug in your Nuxt app that you can't figure out. ​ Parts of the page flicker with wrong data right after you load it. ​ Or worse, users are seeing each other's data. ​ This is one of the most common (and frankly terrifying) bugs that trips up Vue developers when they move to Nuxt, and the worst part is that it's often completely invisible during local development. You could run your test suite a hundred times and never catch it. ​ In this article, you'll learn why ref() can leak data between users in SSR, how useState solves this problem, and when to use each one. ​ If you're building a Nuxt app with server-side rendering, this is something you need to understand. ​ New to universal rendering? Check out What is Universal Rendering?

The Problem: Cross-Request State Pollution

​ Here's a pattern that looks completely reasonable: ​

// composables/useCart.ts — ⚠️ DON'T DO THIS
const cartItems = ref<CartItem[]>([])
export const useCart = () => {
  return { cartItems }
}

​ This works perfectly in a Vue SPA. ​ But in Nuxt with SSR, you've just created a security vulnerability. ​ Here's the thing: your Nuxt server runs as a long-lived Node.js process, and when you define state at the module level like this, that state persists across ALL requests. User A adds items to their cart, User B visits the site five minutes later, and suddenly they're staring at someone else's shopping cart (not a great look for your e-commerce app). ​ This is called cross-request state pollution. ​ The reason you don't catch this locally is that your dev server typically handles one request at a time, so you'd need multiple concurrent users hitting the server simultaneously to see the problem. It slips through to production, where it becomes a security nightmare that's nearly impossible to debug without knowing exactly what you're looking for. ​

Why ref() Works Differently in Nuxt vs Vue SPA

​ In a Vue SPA, each user gets their own app instance running in their browser, so your ref() is completely isolated. There's only one user per app instance, and that user is never going to share their browser with a stranger (well, hopefully not). ​ Nuxt SSR is different. ​ There's one app instance on the server that handles requests for all users, and when a module is loaded, Node.js caches it. That ref() at the top of your composable is created once when the file is first imported, and then shared forever, like a singleton that nobody asked for. ​ Now, ref() is safe inside components. Each request creates fresh component instances. The <script setup> block runs per-instance, so any ref() you create there is scoped to that specific request: ​

<!-- SAFE: ref inside component -->
<script setup>
const count = ref(0)  // Fresh ref for each request/instance
</script>

​ When the request completes, the component instance is discarded. No state leaks because nothing persists between requests, and everyone goes home happy. ​ But ref() is not safe at module level, because module code runs once when the file is first imported, gets cached by the Node.js module system, and then just... stays there: ​

// ⚠️ DANGEROUS: ref at module level
const userData = ref(null)  // Created once, shared forever
export const useUser = () => {
  const fetchUser = async (userId: string) => {
    userData.value = await $fetch(`/api/users/${userId}`)
  }
  return { userData, fetchUser }
}

​ Every request gets the exact same ref. ​ If User A's request fetches their profile during SSR, that data is now sitting in the shared ref when User B's request comes in. User B might briefly see User A's data before their own request completes (and if you're storing anything sensitive like account details, you've got a real problem on your hands). ​

The Solution: useState

​ Nuxt provides useState specifically to solve this problem. It creates request-scoped state using a unique key: ​

// composables/useUser.ts — SAFE
export const useUser = () => {
  const userData = useState('user-data', () => null)
  const fetchUser = async (userId: string) => {
    userData.value = await $fetch(`/api/users/${userId}`)
  }
  return { userData, fetchUser }
}

​ The state is isolated per-request on the server, so User A and User B each get their own user data, exactly how it should be. And as a bonus, useState automatically serializes the data to the Nuxt payload for hydration (more on that in a moment). ​ The key parameter must be unique across your application. If you use the same key in two places, you get the same state. That's intentional (not a bug, a feature!), and it's actually how you share state across components without prop drilling or reaching for a full state management library like Pinia. ​ So use descriptive, namespaced keys: 'cart-items', 'user-preferences', 'sidebar-open'. For a deeper dive on this topic, check out Understanding useState in Nuxt 3, which is still relevant for Nuxt 4. ​

Hydration: The Magic Behind useState

​ When your Nuxt app renders on the server, it creates HTML with some initial state, and then the browser receives this HTML and needs to "hydrate" it, making it interactive by attaching Vue's reactivity system. ​ Here's the problem with ref(): there's no automatic way to transfer that state from server to client, so the client starts with fresh default values. You get hydration mismatch warnings, or worse, silent bugs where the UI flickers as it re-renders with different data (and good luck explaining that to your designer). ​ But useState handles this automatically. The state is serialized into the window.__NUXT__ payload on the server. When the client hydrates, it reads from that payload: ​

// What Nuxt does for you with useState
// Server: serializes state to payload
// Client: reads from window.__NUXT__.state['cart-items']

​ No mismatch and no data loss. The client picks up exactly where the server left off. ​ If you want to understand this better, you can build useState from scratch to see how it works under the hood. ​

When to Use useState vs Pinia

​ We can put our state into useState, but we can also use Pinia in our Nuxt applications. ​ So, how do we decide which one to use? ​ The useState composable is ideal for simple shared state: user preferences, UI state like whether a sidebar is open, that sort of thing. You can get surprisingly far with just useState, and many apps don't need anything more. ​

const isDarkMode = useState('dark-mode', () => false)

​ Pinia is better when you need more structure. Things like complex state with actions and getters, devtools debugging, computed properties, or when you have multiple related stores in a large application that would turn into a tangled mess otherwise: ​

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price, 0)
  )
  const addItem = (item: CartItem) => {
    items.value.push(item)
  }
  return { items, total, addItem }
})

​ One important note: Pinia in Nuxt is also SSR-safe by default. It uses useNuxtApp() internally to scope state per-request. So if you're using Pinia, you don't need to worry about the ref() problem inside your stores. ​

Common Mistakes and How to Avoid Them

​ Here are a few other common state management mistakes to avoid with Nuxt's SSR. ​

1. Using VueUse's createGlobalState in SSR

​ VueUse's createGlobalState has the same singleton problem as module-level ref(), and despite being incredibly useful for client-side apps, it's not SSR-safe. Use useState instead. ​

2. Storing non-serializable data in useState

​ Classes, functions, and symbols can't be serialized to JSON, which catches a lot of developers off guard (especially those coming from backend languages where objects travel freely between processes). If you try to put a class instance in useState, it will break during hydration: ​

// DON'T: class instances
const user = useState('user', () => new User())
// DO: plain objects
const user = useState('user', () => ({ name: '', email: '' }))

​ If you really need custom serialization (for something like Luxon DateTime objects), you can use payload plugins: ​

// plugins/datetime-serializer.ts
export default definePayloadPlugin(() => {
  definePayloadReducer('DateTime', (value) => {
    return value instanceof DateTime && value.toJSON()
  })
  definePayloadReviver('DateTime', (value) => {
    return DateTime.fromISO(value)
  })
})

​ The definePayloadReducer handles serialization on the server, and definePayloadReviver reconstructs the object on the client. ​

3. Forgetting unique keys

​ Duplicate keys cause state collision, and this is one of those bugs that can take hours to track down because everything looks right until you realize two unrelated composables both decided to use 'data' as their key. Always use descriptive, namespaced keys! ​

4. Confusing component scope vs module scope

​ Using ref() at the top of <script setup> is safe. ​ But using ref() at the top of a composable file is dangerous. ​ The rule is simple: ref() inside components, useState() for shared state. This distinction between code that runs on the server vs the client is something you'll encounter often in Nuxt. ​

Quick Reference: ref() vs useState()

​ Here's the quick mental checklist I use when deciding what to use: ​

  • Is it component-local state that doesn't need to be shared? Use ref().
  • Is it shared across components? Use useState().
  • Is it complex with actions, getters, computed properties, and potentially nested stores? Use Pinia.
  • Will it ever run on the server? Never use module-level ref(). ​ | Scenario | Solution | | --- | --- | | Form input binding | ref() | | Shared UI state (sidebar/modal open) | useState() | | Shopping cart | useState() or Pinia | | User authentication | Pinia | | Fetched data | useFetch() / useAsyncData() | | Analytics dashboard | Pinia | ​

Conclusion

​ The rule is straightforward: ref() for component-local state, useState() for shared SSR-safe state. ​ Getting this wrong has real costs: security vulnerabilities, data leaks between users, and the kind of bug reports that make you question your career choices. Once user trust is broken, it's hard to rebuild. ​ Take a few minutes today to audit your composables and look for any ref() or reactive() calls at the module level. Replace them with useState(). ​ It's a small change that can save you from a very bad day, and your users (and your future self debugging at 2am) will thank you.

Michael Thiessen
Michael is a passionate full time Vue.js and Nuxt.js educator. His weekly newsletter is sent to over 11,000 Vue developers, and he has written over a hundred articles for his blog and VueSchool.

Follow MasteringNuxt on