In a browser-only Vue app, a ref declared at the top of a module is a perfectly good piece of shared state. Every component that imports it sees the same value, and the app belongs to one user. In Nuxt that last part stops being true. The same code also renders pages on a server, in one long-lived process that serves every visitor, so a value set while rendering one visitor's page can appear in the next visitor's.
So each piece of state needs a home that keeps visitors apart: a ref inside the component that uses it, useState for state many parts of the app share, or a Pinia store once that state has behaviour of its own. The examples use the Nuxt 4 layout: components and composables in app/, server code in server/.
Why a module-level ref leaks between requests
Here is a composable that looks harmless.
interface User {
id: number
name: string
}
const currentUser = ref<User | null>(null)
export function useCurrentUser() {
return currentUser
}
In the browser it works, because each visitor runs their own copy of the app. On the server, one copy of the module serves everyone. Node loads it once, so currentUser is created once and lives as long as the server process. A request from Alice sets it. A stranger's request a second later renders a page that reads it, and the server's HTML says "Welcome, Alice".
That is a cross-request state leak. Nuxt's state management guide puts it first under best practices: never define a ref outside <script setup> or setup(), because the state is shared across requests on the server and can lead to memory leaks.
The fix is to ask Nuxt for state that belongs to the current request.
interface User {
id: number
name: string
}
export const useCurrentUser = () => useState<User | null>('current-user', () => null)
Nothing is created at module level. Each call asks the current Nuxt app instance for the state under the key 'current-user', and on the server there is one Nuxt app instance per request. Alice's value never meets the stranger's.
How useState works on the server and in the browser
useState(key, init) returns a Ref. The first call with a key runs init and stores the result. Every later call with the same key, in any component, gets a ref to that same value. Two things make it different from a plain ref.
It is scoped to the request on the server, as above. And it is carried across to the browser. When the server finishes rendering, Nuxt serialises every useState value into the page payload, the data Nuxt embeds in the HTML for the browser. When the browser hydrates (Vue takes over the server's HTML), useState with the same key starts from that value instead of running init again. The server and the client agree, which is exactly what hydration needs.
Serialisation sets the one hard limit: the value has to survive the trip into the page. Numbers, strings, booleans, arrays and plain objects are safe, and so are Date, Map and Set, which Nuxt's serialiser (the devalue library) also handles. Keep out what Nuxt's docs name as unserialisable: classes, functions and symbols. A class instance makes the serialiser throw "Cannot stringify arbitrary non-POJOs" (a POJO is a plain old JavaScript object).
For a large array or object that you replace rather than change in place, return a shallowRef from init to skip deep reactivity.
Name your keys. Nuxt can generate one from the file and line, but a named key is easier to find and to clear with clearNuxtState('current-user').
Load app-wide state once with callOnce
State that comes from an API should be fetched once on the server, not again when the browser hydrates. callOnce runs a function during server rendering and records in the payload that it ran, so the browser skips it. Put it in app/app.vue, the component that wraps every page:
<script setup lang="ts">
interface SiteConfig {
name: string
locale: string
}
const siteConfig = useState<SiteConfig | null>('site-config', () => null)
await callOnce(async () => {
siteConfig.value = await $fetch<SiteConfig>('/api/site-config')
})
</script>
<template>
<NuxtPage />
</template>
By default callOnce runs once for the lifetime of the app, so the settings are fetched once each time the app loads, not on every page change. The other mode, { mode: 'navigation' } as the last argument, is for a callOnce in a page component: the function runs again each time a client-side navigation opens that page.
Data a page needs to render, such as a product or a post, belongs in useFetch or useAsyncData instead: they already cache, deduplicate and hydrate it. useState with callOnce is for the small amount of app-wide state that many pages read and none owns: site settings, the current user, feature flags.
Wrap useState in a composable
A bare useState call gets repetitive once several components need the same logic. Wrap it in a composable in app/composables/, where Nuxt auto-imports it.
export interface CartLine {
id: string
qty: number
price: number
}
export function useCart() {
const lines = useState<CartLine[]>('cart', () => [])
const total = computed(() => lines.value.reduce((sum, l) => sum + l.qty * l.price, 0))
function add(line: CartLine) {
const existing = lines.value.find(l => l.id === line.id)
if (existing) existing.qty += line.qty
else lines.value.push(line)
}
function clear() {
lines.value = []
}
return { lines, total, add, clear }
}
The useState call is inside the function, so every caller gets the current request's cart. The computed and the two helpers are created fresh for each component that calls useCart(), which is cheap and correct.
What this pattern does not give you is tooling: no timeline of changes in DevTools, no plugins for persistence, no single place for the $fetch call when the cart has to sync with the server. When you start wanting those, you have reached Pinia.
When Pinia is worth it
Pinia gives each piece of state a name, a home and tooling:
- A store groups state, getters (computed values) and actions (functions that change the state, often by calling an API) in one file.
- Vue DevTools shows every store and a timeline of every change.
- Plugins can persist a store or add logging.
@pinia/testinglets a component test start from a known store state.
None of that matters for a theme toggle. All of it matters once the cart talks to an API, three pages change it, and a bug report says "the total was wrong after I removed an item".
Set up Pinia with @pinia/nuxt
Add Pinia through its Nuxt module, from the project's root:
npx nuxt module add pinia
That installs pinia and @pinia/nuxt and adds the module to nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
})
If pinia is missing from package.json afterwards, install it with npm i pinia, as Pinia's Nuxt guide advises.
The module auto-imports defineStore, storeToRefs and acceptHMRUpdate, and every store in app/stores/ is auto-imported by its export name. Here is the same cart as a setup store, a store written as one function that returns its state, getters and actions:
import type { CartLine } from '~/composables/useCart'
export const useCartStore = defineStore('cart', () => {
const lines = ref<CartLine[]>([])
const total = computed(() => lines.value.reduce((sum, l) => sum + l.qty * l.price, 0))
async function checkout() {
await $fetch('/api/checkout', { method: 'POST', body: { lines: lines.value } })
lines.value = []
}
return { lines, total, checkout }
})
Notice the plain ref inside defineStore. This is not the leak from the first section, because the setup function does not run at module level. Pinia runs it the first time the store is used, against a Pinia instance that @pinia/nuxt creates per request on the server.
Hydration works as it does for useState. After rendering, the module puts every store's state into the payload, and in the browser it restores that state before the components render, so the same serialisation limit applies. Return every piece of state from the setup function: Pinia's docs warn that state left out of the return value breaks SSR and DevTools.
In a component, useCartStore() is available without an import, and storeToRefs takes state and getters out of the store as refs, so they stay reactive.
<script setup lang="ts">
const cart = useCartStore()
const { total } = storeToRefs(cart)
</script>
<template>
<span>{{ total.toFixed(2) }}</span>
</template>
Choose where each piece of state lives
Use a plain ref inside a component for state that belongs to that component: an open menu, a draft in a form field, a hover. Never declare one at module level in a Nuxt project, not even "just for now".
Use useState for state many parts of the app read and that has little logic of its own: the current user, site settings, a theme, a cart in a small shop. Wrap it in a composable so the key lives in one place, and initialise it with callOnce when it comes from an API.
Use useFetch and useAsyncData for server data a page renders. Do not copy their results into useState or a store: copying creates two sources of truth.
Use Pinia when state has behaviour: actions that call the server, getters that several pages share, a change history you need to see in DevTools, a plugin you want to add, or a test that has to start from a known state. Once you have one store, keep adding stores rather than mixing the two approaches for the same data.