Stop Using ref() in Nuxt SSR: How to Avoid SSR State Nightmares
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:
- Your server is a long-running Node.js process.
- It serves multiple users at the same time.
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.
- User A adds a gadget
- User B logs in like, “Wait, why do i own a disco-dancing unicorn plushie named Sir Fluffington?”
Capitalism, but make it cursed.
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:
- One user
- One browser
- One request at a time
Everything feels fine.
In production:
- Multiple users
- Concurrent requests
- Shared memory
And suddenly:
- Wrong user data
- Flickering UI
- “I swear this worked yesterday” energy
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:
- ✅
ref()inside components is totally safe - ❌
ref()at module scope in composables is where things go wrong
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?
- You provide a key
- Nuxt creates isolated state per request
- No leaking
- No ghosts
- No shared carts from strangers
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:
- Server state isn’t preserved properly
- Client recalculates everything
- Hydration mismatches happen
- UI flickers like a haunted website
useState()? Pure wizardry:
- Server stores the state in the payload
- Client restores it exactly
- No mismatches
- No flicker
- No “why did this rerender?”
It just… works.
The good kind of magic ✨
ref(), useState(), or Pinia: When to Use What (Without Overthinking It)
ref(): For stuff stuck in one component, like a form field or a click counter. Local only, no sharing.useState(): Perfect for lightweight shared bits across components. Think dark mode switches, user prefs, or a no-frills cart.- Example:
const prefersDarkness = useState('darkness-mode', () => false) // Because some users secretly worship the moon 🌙
- Example:
- Pinia: Level up for fancy state with actions, getters, and devtools love. Nuxt makes it SSR-safe out of the box.
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)
- Skip VueUse’s
createGlobalState()in SSR—it’s got the same singleton vibes asref(), Same problem, different outfit. Total no-go. - Keep
useState()data simple and JSON-friendly. No fancy classes or functions—they’ll choke on serialization. Name them like you mean it- Bad move:
// ❌ no, Nuxt cannot serialize your ambitions const wizard = useState('wizard', () => new Wizard()) - Smart move:
// ✅ boring, predictable, and SSR-approved const wizard = useState('wizard', () => ({ name: '', level: 1, }))
- Bad move:
For dates or weird types, hook up payload plugins to handle the heavy lifting.
- Unique keys or bust. Duplicates? Recipe for accidental sharing.
- Remember:
ref()in composables = red flag.useState()= green light.
Quick Cheat Sheet: Pick Your Fighter
| Scenario | Best Choice | Why |
|---|---|---|
| Input in a form | ref() | Stays local, no drama |
| Dark mode toggle | useState() | Shared but safe |
| Shopping cart with totals | useState() or Pinia | Basic? useState(). Fancy? Pinia |
| User login data | Pinia | Actions and security needed |
| API-fetched goodies | useAsyncData() 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 🛒