In a server-rendered storefront using Pinia, why must each request create its own pinia, and why is useAuthStore() after an await in a guard risky?
answer
- one Node process, many shoppers
- the store cache lives in the pinia
- active pinia is a module global
- injection context ends at await
- pass the pinia explicitly
basics
~20 sA pinia caches every store, so one shared pinia serves one shopper's cart to the next; create it per request. After an await, a store call falls back to the process-wide active pinia, so pass the request's pinia explicitly.
solid answer
~50 sA pinia holds the **store cache** and the root state. On a server one process renders many requests, so a pinia created at module scope would hand shopper A's cart store to shopper B. The pinia therefore has to be created inside the function that builds the app for each request, next to `createSSRApp()` and the router. Inside components that is enough, because `useStore()` injects the pinia of its own app. Outside `setup()` it is not: Pinia falls back to the **active pinia**, a module-level variable the last request touched. Vue Router 5 runs guards inside `app.runWithContext()`, so a store call at the top of a guard injects correctly, but after an `await` the context is gone and the call silently takes the active pinia, which may belong to another request. Writing `useAuthStore(pinia)` with the request's pinia removes the ambiguity.
code
ts · 25 linesimport { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
import App from './App.vue'
import { routes } from './routes'
import { useAuthStore } from './stores/auth'
export function createStorefront(isServer: boolean) {
const app = createSSRApp(App)
const pinia = createPinia()
const router = createRouter({
history: isServer ? createMemoryHistory() : createWebHistory(),
routes,
})
app.use(pinia)
app.use(router)
router.beforeEach(async (to) => {
const auth = useAuthStore(pinia)
if (!auth.checked) await auth.loadSession()
if (to.meta.requiresAuth && !auth.user) return '/login'
})
return { app, router, pinia }
}go deeper
Recall that a server-rendered Pinia app creates a new pinia for every request, inside the same factory that creates the app.
Explain that the pinia caches stores by id, so a shared pinia shares stores, and how components find their own app's pinia by injection.
Show why the active-pinia fallback leaks under concurrency, why the injection context ends at the first await, and how passing the pinia explicitly fixes it.
Decide how to make per-request correctness structural: factories that close over the pinia, review rules for awaits before store calls, and load tests that exercise concurrency.
## What a pinia holds The object returned by `createPinia()` is not just a plugin. It holds: - the **store cache**: every store created so far, keyed by id; - the **root state**, `pinia.state`, holding every store's state; - the installed plugins and an effect scope for the stores. In a browser there is one user, so one pinia for the page's lifetime is correct. On a server, one Node process renders pages for many shoppers, often concurrently. If the server entry created one pinia at module scope, the first shopper's `cart` store would be cached in it and returned to every later request: shopper B would see shopper A's items and login. The general reason module-level state leaks on a server belongs to Vue's SSR rules; the Pinia-specific point is that **the pinia is the thing that must be per request**, because the cache lives inside it. ## One pinia per request Create the pinia in the same factory that creates the app and the router: ```ts export function createStorefront(isServer: boolean) { const app = createSSRApp(App) const pinia = createPinia() const router = createRouter({ history: isServer ? createMemoryHistory() : createWebHistory(), routes, }) app.use(pinia) app.use(router) router.beforeEach(async (to) => { const auth = useAuthStore(pinia) if (!auth.checked) await auth.loadSession() if (to.meta.requiresAuth && !auth.user) return '/login' }) return { app, router, pinia } } ``` The server calls `createStorefront(true)` for every request; the browser calls it once. ## How a store call finds the right pinia | Where the call runs | How Pinia finds the pinia | Safe on the server? | |---|---|---| | component `setup()` | `inject()` from its own app | yes | | top of a Vue Router guard | `inject()`: the router runs guards in `app.runWithContext()` | yes, until the first `await` | | after an `await` in a guard | the **active pinia** fallback | no | | with an explicit argument, `useAuthStore(pinia)` | the argument | yes | The **active pinia** is a single module-level variable. `app.use(pinia)` sets it, every successful store lookup sets it again, and every action sets it when it starts. On a busy server it therefore points at whichever request touched Pinia last. When a call has no argument and no injection context, Pinia quietly uses it; there is no error as long as some request set it. ## Why the await matters `app.runWithContext()` makes injection available only while the callback runs **synchronously**. An `await` suspends the guard; when it resumes, the context has been restored to nothing, and other requests may have run in between. A second `useAuthStore()` after the `await` therefore gets the active pinia, possibly another shopper's. The failure is intermittent and only appears under concurrency, which is why it survives local testing. The robust habits: 1. Call stores at the **top** of a guard, before any `await`, and keep the reference. 2. Or pass the request's pinia explicitly, as in the factory above; the guard is a closure over its own request's `pinia`. 3. Apply the same rule in actions that call other stores: call them before the first `await` in the action. 4. In Options API server code, `this.$pinia` gives the current app's pinia, because `app.use(pinia)` also registers it as a global property. 5. Keep helper modules pure: a service function that needs a store should receive the pinia (or the store) as an argument instead of calling `useAuthStore()` on its own, so it can never pick up the wrong request's instance. ## Signals that something is wrong - If code calls `getActivePinia()` on the server where no pinia can be injected, development builds report the coded diagnostic `PINIA_R1004`, warning that the fallback exposes you to cross-request pollution. A plain `useStore()` call falls back without that report. - Users report seeing another user's name or cart for a moment, typically under load. - Tests that render one request at a time never reproduce it. ## Common mistakes - Creating the pinia at module scope in the server entry and the app per request. - Assuming a guard never has an injection context and passing pinia everywhere out of fear, or the opposite, trusting it after `await`. - Treating the silent fallback as proof the code is correct.
- Why do the Pinia docs tell you to call stores at the top of actions and getters during SSR?Pinia sets the active pinia when an action or getter starts, so a store call at the top resolves to the right request. After an `await` inside the action, other requests may have changed the active pinia, so a store first called there can belong to someone else. Resolve the other stores first, then await.
- In an Options API component rendered on the server, how does serverPrefetch() get the right pinia?Through `this.$pinia`. Installing the pinia with `app.use(pinia)` also registers it as a global property, so `useProductStore(this.$pinia)` inside `serverPrefetch()` targets the current request's pinia. In `<script setup>` with `onServerPrefetch()` nothing special is needed, because the store is called during setup.
saying these in an interview costs you the question
- One pinia at module scope is fine on the server because stores are functions
- useStore() after an await in a guard throws, so the bug is always visible
- Vue Router guards never have an injection context, so stores always need pinia
- The active pinia is stored per request, so the fallback is always correct
- Creating the app per request is enough even if the pinia is shared