Stop Using ref() in Nuxt SSR: How to Avoid SSR State Nightmares

Stop Using ref() in Nuxt SSR: How to Avoid SSR State Nightmares

Shubham Santosh Kandalgaonkar

Junior FullStack Engineer

If you’ve built anything serious with Nuxt, chances are you’ve reached for ref() everywhere. And honestly? That instinct makes sense. It’s simple, reactive, and feels like home—pure Vue muscle memory.

But once Server-Side Rendering enters the picture, that comfort can turn your app into a horror movie.

Not the jump-scare kind. The “why is one user seeing another user’s data?” kind.

That’s when your app stops feeling magical and starts acting like a communal fridge—everyone grabbing the wrong lunch, and no one knowing how it happened.

Let’s talk about why this happens — and how useState() saves you from debugging at 3 AM.

The Core Problem: SSR is Not a SPA Playground

Nuxt’s SSR is awesome—it pre-renders pages on the server first for speedy loads and SEO wins. The server spits out ready-to-go HTML straight to the browser. But here’s the plot twist: that server is a Node.js beast that sticks around, juggling requests from tons of users like a caffeinated octopus. And here’s the part people forget:

If you slap a ref() outside a component—say, at the top of a composable— it turns into a shared toy. Everyone plays with the same one.

Check out this code that’s basically asking for trouble:

// composables/useCart.ts — 🚨 cursed code, do not ship
const cartItems = ref<string[]>(['Mystery Sock', 'Disco Ball', 'Rubber Chicken'])

export const useCart = () => {
  return { cartItems }
}

Looks fine, right? No warnings. No red squiggles. No console errors.

But Node.js loves caching modules, so this ref() gets created once and hangs out forever. It’s like that one meme where the dog sits in a burning room saying “This is fine”—except it’s not, because now all users are sharing the same cart.

Cross-request state pollution at its finest. Data leaks everywhere, and suddenly your app’s a security meme waiting to go viral.

Why This Bug Is So Hard to Notice

In development:

Everything feels fine.

In production:

And suddenly:

It’s sneaky. In dev mode, you’re usually solo-testing, so everything looks peachy. But deploy to production with real traffic? Chaos ensues.

Another prime offender:

// 🚫 this will emotionally damage your users
const userData = ref<{ id: string; name: string; hobby: string } | null>({
  id: '0001',
  name: 'Alice',
  hobby: 'Competitive Duck Racing'
})

export const useUser = () => {
  const fetchUser = async (id: string) => {
    userData.value = await $fetch(`/api/users/${id}`)
  }

  return { userData, fetchUser }
}

Now user data is playing musical chairs across sessions. User A’s profile pops up for User B. It’s like accidentally FaceTiming the wrong person—awkward and potentially lawsuit-level bad.

“But ref() Works Fine in Vue…”

Correct — in SPAs.

In a plain Vue SPA, each user’s browser runs its own little world. State? Totally private, like your secret snack stash. But Nuxt SSR? One server for all, so module-level ref() becomes a singleton party crasher—shared by the whole crowd

The silver lining:

The Hero We Need: Switch to useState()

Nuxt gives us useState() for a reason. It’s not just a fancy ref() — it’s SSR-aware state. It whips up state that’s locked down per request, no sharing unless you want it.

Here’s the glow-up version of the cart example:

// composables/useCart.ts — ✅ SSR-safe, drama-free
export const useCart = () => {
  const cartItems = useState('cart-items', () => ['Invisibility Cloak', 'Banana Peel', 'Quantum Sandwich'])
  return { cartItems }
}

What changed?

That unique key (‘cart-items’) is your secret sauce—it tells Nuxt, “Hey, make a fresh one for each user.” If you reuse the same key elsewhere, the state is shared intentionally — not by accident.

Intentional shared state = good
Accidental shared state = therapy

Pro tip: Pick keys that scream what they do, like ‘user-preferences’ or ‘dark-mode-toggle.’ No more generic ‘stuff’ that leads to mix-ups.

Hydration: Where useState() Really Shines

SSR doesn’t stop at the server.

After rendering HTML, Nuxt sends a payload to the browser. The browser hydrates it — turning static HTML into a live app by making everything clickable with Vue’s reactivity.

With plain ref(), it’s a mismatch nightmare:

useState()? Pure wizardry:

It just… works.
The good kind of magic ✨

ref(), useState(), or Pinia: When to Use What (Without Overthinking It)

From the Nuxt docs themselves: “useState is an SSR-friendly ref replacement.” Boom.

Pitfalls to Dodge (So You Don’t End Up in Dev Hell)

For dates or weird types, hook up payload plugins to handle the heavy lifting.

Quick Cheat Sheet: Pick Your Fighter

ScenarioBest ChoiceWhy
Input in a formref()Stays local, no drama
Dark mode toggleuseState()Shared but safe
Shopping cart with totalsuseState() or PiniaBasic? useState(). Fancy? Pinia
User login dataPiniaActions and security needed
API-fetched goodiesuseAsyncData() or useFetch()Built-in fetching pros

Wrapping It Up

If you remember just one rule, make it this:

ref() inside composables + SSR = danger
useState() inside composables = peace

Audit your composables.

Find module-level ref() calls. Replace them with useState().

Your users will stop seeing each other’s data. Your app will stop behaving like it’s possessed. And future-you will silently say, “Thank you.”

Now go refactor — before your cart starts selling mystery items again 🛒

Recent Blog Posts

Holidays List 2026 – XploreBits HQ

This post outlines the official list of holidays for XploreBits HQ employees for the year 2026. Please review the dates in advance to plan your work, leaves, and personal time accordingly.