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?
answer
- after the render, not before
- one object holds every store
- escape it for script tags
- assign before any useStore
- creation reads initial state once
basics
~20 sAfter 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.
solid answer
~50 sEvery store's state lives in one root object, `pinia.state.value`, keyed by store id. On the server, after `renderToString()` finishes, it holds the state of every store the render used; I serialise it and embed it in the HTML, **escaped**, since product reviews or search terms are user input and a raw `</script>` would break out of the tag. The Pinia docs suggest `devalue`, or `JSON.stringify` plus escaping if the data is plain. On the client I create the pinia, `app.use(pinia)`, then assign `pinia.state.value = parsed` **before** anything calls a store. That order matters because a store reads its initial state once, when created: an option store skips `state()` when its entry already exists, and a setup store copies the entry into its refs. A store created before the assignment keeps its defaults and the page mismatches the server HTML.
code
ts · 15 linesimport { renderToString } from 'vue/server-renderer'
import { createStorefront } from './app'
import { serializeState } from './serialize-state'
export async function render(url: string) {
const { app, router, pinia } = createStorefront(true)
await router.push(url)
await router.isReady()
const html = await renderToString(app)
const state = serializeState(pinia.state.value)
return `<div id="app">${html}</div>` +
`<script>window.__pinia = ${state}</script>`
}go deeper
Recall that Pinia keeps all store state in pinia.state.value, and that server-rendered state must be sent to the browser and restored there.
Explain the order: serialise after the render, escape it, and assign it on the client before any store is created.
Explain why the order matters (option stores skip state(), setup stores copy the entry into refs), the XSS risk of raw JSON, and what must never reach the payload.
Decide whether to hand-roll SSR state transfer at all, weighing serialiser choice, payload size and secrets against adopting a framework that owns it.
## What there is to ship Pinia keeps every store's state in one **root state**, `pinia.state`, a ref whose value is an object keyed by store id: ```ts pinia.state.value // { cart: { lines: [...] }, catalog: { products: [...], page: 2 } } ``` When the server renders a page, components call their stores, actions fetch data, and each store's state lands in that object. After the render, `pinia.state.value` is a complete description of what the HTML was built from. Hydration on the client needs the same data, otherwise the browser would render empty stores over server HTML that shows products. ## On the server: serialise after rendering 1. Create the app and pinia for this request. 2. Render: `const html = await renderToString(app)`. 3. **Then** read `pinia.state.value`; reading it before the render would miss everything fetched during it. 4. Serialise and escape it, and embed it in the page, for example as a script that assigns a global. Escaping is not optional. Store state usually contains user-controlled strings: a product review, a display name, a search query. If one contains `</script><script>...`, a plain `JSON.stringify` embedded in a `<script>` tag lets it end the tag and run code. The Pinia docs recommend an escaping serialiser such as `devalue` (the one Nuxt uses), or `JSON.stringify` with `<` escaped when the state is plain data, which is also faster. | Serialiser | Handles | Watch out for | |---|---|---| | `JSON.stringify` + escaping | plain objects, arrays, strings, numbers | `Date` becomes a string; `Map`, `Set` and `undefined` are lost | | `devalue` | also `Date`, `Map`, `Set`, repeated references | slower than plain JSON on large payloads | Whatever you choose, the state must be serialisable. Class instances, functions and DOM references do not survive the trip, which is one reason stores should hold plain data. ## On the client: restore before any store call ```ts const app = createSSRApp(App) const pinia = createPinia() app.use(pinia) if (window.__pinia) { pinia.state.value = parseState(window.__pinia) } const router = createAppRouter() app.use(router) await router.isReady() app.mount('#app') ``` The assignment must come **before** anything calls a store: before the router is installed and starts its first navigation (whose guards may call stores), before components mount, and before any plugin or startup code touches a store. Here `parseState` stands for whatever inverse your serialiser provides, and `createAppRouter` for your router factory. ## Why the order matters A store reads its initial state exactly once, when it is created on the first `useStore()` call: - An **option store** checks whether `pinia.state.value[id]` already exists. If it does, it **skips `state()`** and uses the hydrated entry; if not, it calls `state()` and writes the result into the root state. - A **setup store** always runs its setup function and creates its refs, then, if an entry for its id exists, copies each value from that entry into the matching returned ref. If a guard or plugin creates the `cart` store before the assignment, the store has already initialised from defaults. Replacing `pinia.state.value` afterwards does not rebuild it, so the first client render shows an empty cart while the server HTML shows items: a hydration mismatch, and possibly a flash of wrong content. ## Checking that it works A hydration setup is easy to get almost right. Three checks catch most mistakes: 1. View the page source: the embedded state should contain the data the HTML shows, and no raw `<` characters from user content inside the script. 2. Open the browser console in a development build: Vue reports hydration mismatches, which usually mean a store was created before the state was restored or a value differs between server and client. 3. Watch the network panel on first load: if the client refetches data the server already rendered, the stores started from defaults instead of the hydrated state. The usual cause of all three is ordering, so read the client entry top to bottom and confirm nothing calls a store before the assignment. ## What goes into the payload, and what should not - Only stores that were created during the render appear; untouched stores are absent and initialise normally on the client. - Secrets fetched on the server (tokens, internal flags) end up in the page if they are in state. Keep them out of stores that render on the server. - Values that must come from the browser, not the server, need a per-property opt-out from hydration. ## Common mistakes - Serialising `pinia.state.value` before `renderToString()` resolves. - Embedding raw `JSON.stringify` output in a script tag. - Restoring state after `app.mount()` or after the router's first navigation. - Putting non-serialisable objects into state and wondering why they arrive as `{}`.
- Why does an option store skip state() on the client when hydrated state exists, and what hook covers values that cannot be serialised?Calling `state()` would create default values and overwrite what the server produced, so Pinia uses the existing entry instead. For values that must be rebuilt in the browser, such as a ref bound to browser storage, an option store can define `hydrate(storeState, initialState)`, which Pinia calls when the store is created with an initial state.
- What happens to a Map in a setup store's state when the client hydrates it?For a reactive `Set` or `Map` returned by a setup store, Pinia clears the collection first and then merges the hydrated entries in, so defaults created in setup do not mix with the server's values. The serialiser must also support Map, which plain JSON does not.
saying these in an interview costs you the question
- pinia.state.value can be serialised before the render starts
- Plain JSON.stringify output is safe to embed inside a script tag
- Restoring pinia.state.value after mount updates stores that already exist
- An option store always calls state() even when hydrated state exists
- Every store is included in the payload whether or not the page used it