skip to content

SSR & Non-Component Use

A store needs an active pinia, so using one outside a component or on the server can fail or leak state. Interviewers ask about router guards, one pinia per request and hydrating before first use.

on this pageshow

explore

questions

5

In a Pinia single-page app, why does calling useAuthStore() at the top of the router module fail, and where should the call go?

level: middleimportance: must knowfreq 60%

answer

  1. import time versus run time
  2. where useStore finds its pinia
  3. app.use(pinia) sets the active one
  4. no active Pinia error
  5. move the call into the guard

basics

~10 s

A store call needs a pinia; module-level code runs at import, before app.use(pinia) makes one active, so Pinia throws a no-active-Pinia error in development. Call useAuthStore() inside the guard function, which runs after installation.

solid answer

~40 s

`useAuthStore()` has to find a pinia: first one passed as an argument, then one it can `inject()` because it runs inside a component or an app context, and finally the module-level **active pinia** that `app.use(pinia)` sets during installation. A call at the top of `router/index.ts` runs when the module is imported, typically before `main.ts` reaches `app.use(pinia)`, so none of the three exists. In development Pinia throws `"getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?`; in production the code fails on an undefined pinia. The fix is to defer the call: put `const auth = useAuthStore()` inside the `beforeEach` callback, which only runs during navigation, after both plugins are installed.

code

ts · 15 lines
ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { routes } from './routes'

export const router = createRouter({
  history: createWebHistory(),
  routes,
})

router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})

go deeper

for a junior

Recall that a Pinia store call needs app.use(pinia) to have run first, and that router guards are a safe place to call it.

for a middle

Explain the lookup order (argument, injection, active pinia) and why code at module scope runs at import time, before any of them exists.

for a senior

Spot import-order bugs that only appear in some builds, and explain why the single-page fallback to the active pinia is not safe under server rendering.

for a principal

Set a codebase rule that stores are only called inside functions that run after installation, and enforce it in review or linting.

## How useStore() finds its pinia A function returned by `defineStore()` (here `useAuthStore`) does not own any state. It looks up the **pinia** instance, the container created by `createPinia()`, and asks it for the store with that id, creating the store on first use. It resolves the pinia in a fixed order: 1. **An explicit argument**: `useAuthStore(pinia)`. 2. **Injection**: if `hasInjectionContext()` is true (inside a component's `setup()`, or inside `app.runWithContext()`), it calls `inject()` to get the pinia provided by `app.use(pinia)`. 3. **The active pinia**: a module-level variable that `app.use(pinia)` sets during installation and that every successful lookup refreshes. If all three are missing, a development build throws: > "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"? ... This will fail in production. A production build skips the check and fails a line later, reading properties of an undefined pinia. ## Why the router module hits it ```ts import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() export const router = createRouter({ /* ... */ }) ``` That top-level line runs **when the module is imported**. `main.ts` imports the router module near the top, before it calls `createPinia()` and `app.use(pinia)`, so at that moment there is no argument, no injection context and no active pinia. Whether it fails can even depend on import order: if some other path happens to install Pinia first, the same line works, which makes the bug look random. ## The fix: defer the call Move the call into code that runs after installation. A navigation guard is ideal, because the router starts navigating only once it is installed, and its guards run asynchronously, after `main.ts` has also installed Pinia: ```ts router.beforeEach((to) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { return { path: '/login', query: { redirect: to.fullPath } } } }) ``` The store is created on the first navigation and cached in the pinia, so calling `useAuthStore()` on every navigation is cheap: later calls return the same store. Other places that are safe for the same reason: - inside actions and getters of another store; - inside event handlers, timers and callbacks that run after the app is mounted; - inside functions exported from a service module, as long as they are called later rather than at import. ## Where each call site lands | Call site | Pinia found via | Works in a single-page app? | |---|---|---| | top of a module, before `app.use(pinia)` | nothing | no: throws in development | | `<script setup>` of a component | injection | yes | | inside `router.beforeEach()` | injection (the router runs guards in the app's context) | yes | | after `app.use(pinia)` in `main.ts` | active pinia | yes | In a single-page app, once `app.use(pinia)` has run, even a call with no injection context works, because the active pinia is set and there is only one. Server rendering changes that, because many requests share one server process; the rules there are stricter. ## Diagnosing it in an existing app When the error appears in a codebase you did not write, a few checks locate it quickly: 1. Read the stack trace: the failing frame is a module's top level (often a router, an API client or a composable file), not a component. 2. Open `main.ts` and note the order of imports versus `createPinia()` and `app.use(pinia)`; everything imported above them runs first. 3. Search for store calls that sit outside any function body, including default parameter values and module-level constants. 4. If the error only appears in one build or one entry point, compare the import order between them; a lazy-loaded route can hide the problem in one and expose it in another. The same message appears in unit tests that call a store without an active pinia; the testing helpers exist for that case. ## What not to do - Do not work around it by moving `createPinia()` into the router module or calling `setActivePinia()` by hand in app code; that hides the ordering problem and creates a second source of truth. - Do not cache the store in a module-level variable filled lazily "the first time", which reintroduces a global. ## Common mistakes - Believing the store is created when `defineStore()` runs; it is created on the first `useStore()` call. - Assuming the production build behaves like development and only logs a warning. - Calling the store at module scope because it "worked on my machine" with a different import order.

  • Is it expensive to call useAuthStore() on every navigation instead of once at module level?
    No. Only the first call creates the store; the pinia caches it by id, so every later call returns the same reactive store object. The cost is a lookup, not a new store, and the state is shared across all callers.
  • Why does useAuthStore() inside a Vue Router 5 guard find the pinia through injection rather than the global fallback?
    Vue Router runs each guard inside `app.runWithContext()` of the app it is installed in. That makes `hasInjectionContext()` true for the synchronous part of the guard, so `useAuthStore()` injects the app's pinia directly, which is also what keeps guards correct under server rendering.

saying these in an interview costs you the question

  • defineStore() creates the store as soon as the module is imported
  • The no-active-Pinia error is only a development warning, production works
  • Calling the store on every navigation creates a new store each time
  • Calling setActivePinia() in the router module is the proper fix
  • Stores can only be used inside components, never in router guards
open as a page

In Nuxt 4, how do you add Pinia through @pinia/nuxt, and what SSR work does the module do so you do not write it?

level: juniorimportance: should knowfreq 36%

basics

~20 s

Install pinia and @pinia/nuxt and list '@pinia/nuxt' in modules. The module creates and installs a pinia per request, ships its state in Nuxt's payload, restores it on the client, and auto-imports defineStore, storeToRefs and your stores.

open as a page

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?

level: seniorimportance: should knowfreq 41%

basics

~20 s

A 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.

open as a page

In a hand-rolled SSR storefront using Pinia, how do you ship pinia.state.value to the browser, and why must it be restored before any store is used?

level: seniorimportance: should knowfreq 43%

basics

~20 s

After rendering, serialise pinia.state.value with an escaping serialiser into the page; on the client, assign it back to pinia.state.value right after app.use(pinia), before any store call, because stores read their initial state only when created.

open as a page

In a server-rendered Pinia setup store, a recently-viewed list read from localStorage is wiped on hydration; why, and what does skipHydrate() change?

level: seniorimportance: nice to knowfreq 23%

basics

~20 s

During client hydration a setup store copies the server's value into each returned ref, so the server's empty list overwrites the browser's. skipHydrate() on that ref makes Pinia skip it; option stores use a hydrate() option instead.

open as a page