skip to content

A Vue 3 `<script setup>` component reads localStorage at the top of setup and the server render crashes. Why, and how do you fix it?

level: juniorimportance: must knowfreq 62%

answer

  1. which code runs in Node
  2. setup runs on both sides
  3. mount hooks skipped on server
  4. server-safe default, read later

basics

~20 s

Setup code, including the whole root scope of script setup, runs on the server, where there is no visitor's localStorage. Start from a server-safe default and read localStorage inside onMounted, which Vue never calls during SSR.

solid answer

~50 s

In Vue 3 SSR the root scope of `<script setup>` runs on the server for every render, but the server is not a browser: where the runtime has no `localStorage` global the access throws and the render fails, and where one exists it is the server's storage, not the visitor's. Vue does not run `onBeforeMount`, `onMounted` or the update and unmount hooks on the server, so the fix is to initialise the ref with a default the server can produce and read `localStorage` in `onMounted`, which runs only in the browser after hydration. A `typeof window !== 'undefined'` guard stops the crash, but if the guarded value reaches the markup the client's first render differs from the server HTML. The same rule covers timers and listeners: start them in `onMounted`, because the server never unmounts anything to clean them up.

code

vue · 22 lines
vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const theme = ref<'light' | 'dark'>('light')
const now = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | undefined

onMounted(() => {
  const saved = localStorage.getItem('theme')
  if (saved === 'light' || saved === 'dark') theme.value = saved
  now.value = Date.now()
  timer = setInterval(() => (now.value = Date.now()), 1000)
})

onUnmounted(() => clearInterval(timer))
</script>

<template>
  <div :class="`theme-${theme}`">
    <span v-if="now !== null">{{ new Date(now).toLocaleTimeString() }}</span>
  </div>
</template>

go deeper

for a junior

Remember the split: setup runs on the server and in the browser, onMounted only in the browser. Browser APIs such as localStorage, window and document belong in onMounted.

for a middle

Explain why onBeforeMount and a typeof window guard are not equivalent to onMounted: both read the value before the client's first render, so rendered output can differ from the server HTML.

for a senior

Treat every component as universal code: audit setup for browser globals, timers and subscriptions, and move request-specific preferences into something the server can read, such as a cookie.

for a principal

Decide where user preferences live so SSR can honour them: server-readable preferences avoid a visible flash, browser-only ones keep the server simple but always paint the default first.

## What actually runs on the server In Vue 3 **server-side rendering (SSR)**, the server renderer from `vue/server-renderer` creates each component instance, runs its **setup**, and turns its template into an HTML string. For a `<script setup>` component, "setup" is the entire root scope of the script block: every top-level statement executes on the server, once per render. What Vue does **not** run on the server: - `onBeforeMount` and `onMounted`: there is no DOM to mount into. - `onBeforeUpdate` and `onUpdated`: nothing re-renders on the server. - `onBeforeUnmount` and `onUnmounted`: the server never unmounts a component. In Vue's source, registering those hooks during server setup is simply a no-op. The lifecycle hook that is kept is `onServerPrefetch`. In the Options API, `beforeCreate` and `created` are the only regular hooks called during SSR. ## Why the localStorage read breaks `window`, `document` and `localStorage` are **browser globals**. The same component file is **universal code**: it is imported by the server build and the client build. Reading `localStorage` at the top of setup therefore fails in one of two ways: 1. The server runtime has no such global, so the first access throws and the whole render fails. 2. The runtime does provide a storage global, but it belongs to the server process and is shared by every request. It never holds the visitor's saved preference, so the value is wrong even though nothing throws. ## The fix: a server-safe default, read in onMounted 1. Initialise the state with a value the server can compute: a default, or something derived from the request. 2. Read the browser API inside `onMounted`, which runs only in the browser, after hydration. 3. Assign the saved value to the ref; Vue patches the DOM once, after the page is already interactive. ```vue <script setup lang="ts"> import { ref, onMounted } from 'vue' const theme = ref<'light' | 'dark'>('light') // same on server and first client render onMounted(() => { const saved = localStorage.getItem('theme') // browser only if (saved === 'light' || saved === 'dark') theme.value = saved }) </script> ``` The update after mount is ordinary client reactivity: the ref changes, the component re-renders, and the patched class or text replaces the default. ## Comparing the options | Approach | On the server | Client's first render | Outcome | |---|---|---|---| | Read at the top of setup | throws or reads the wrong storage | saved value | render fails or leaks wrong data | | `typeof window` guard in setup | default | saved value | markup differs from server HTML if the value is rendered | | Read in `onBeforeMount` | skipped | saved value, read before the hydration render | same difference as the guard | | Read in `onMounted` | skipped | default, then saved value | consistent HTML, one update after hydration | The guard is fine when the guarded value never reaches the markup, for example registering a listener or reading a feature flag used only in event handlers. When it does reach the markup, the server HTML and the client's first render disagree, which is the hydration-mismatch problem. ## Side effects that need cleanup The same reasoning applies to anything with a teardown: - `setInterval` or `setTimeout` chains started in setup keep running in the server process, one set per request. - Event listeners on globals and subscriptions to sockets or stores accumulate the same way. - The cleanup you would write in `onUnmounted` never runs on the server, because nothing is unmounted. Start such effects in `onMounted` and stop them in `onUnmounted`, so they exist only in the browser. ## Spotting the problem in review When reviewing a component that will be server-rendered, scan the root scope of `<script setup>`, and every composable it calls, for: - direct reads of `window`, `document`, `navigator`, `localStorage` or `sessionStorage`; - third-party imports that touch those globals as soon as their module is evaluated, which fail before setup even runs; - values that depend on the visitor's environment, such as viewport size or time zone, whose server value can only be a guess. Each one either moves into `onMounted`, is replaced by data the server receives with the request, or is kept out of the rendered markup. ## When the first paint must already be right Reading in `onMounted` means the default is visible until hydration finishes. If that flash is unacceptable, move the preference somewhere the server can see it, typically a cookie sent with the request. The server handler reads it and hands it to that request's app instance, so both sides render the same value from the start.

  • Why is starting a setInterval at the top of `<script setup>` a problem in Vue SSR even though it does not throw?
    Setup runs on the server for every request, but `onBeforeUnmount` and `onUnmounted` never run there, so the cleanup you wrote is never called. Each request leaves a live timer in the server process, holding references to that request's state, which leaks memory and CPU. Start the timer in `onMounted` and clear it in `onUnmounted`, so it only exists in the browser.
  • Does wrapping the read in a `typeof window !== 'undefined'` check make the component SSR-safe?
    It stops the crash, but on the client the guarded branch runs during setup, before hydration, so the first client render uses the saved value while the server HTML used the default. If that value reaches the markup, Vue sees a mismatch and patches the DOM. The guard is only enough when the value does not affect what is rendered.
  • The saved theme must be correct in the very first paint. What can you do when `onMounted` is too late?
    Keep the preference somewhere the server can read, usually a cookie sent with each request. The server handler reads it and passes it into that request's app instance, for example through an app-level provide, so server and client render the same theme from the start. Browser-only storage can never be read before the server responds.

saying these in an interview costs you the question

  • Script setup code only runs in the browser, so window is always there.
  • onMounted runs on the server right after the HTML string is produced.
  • A typeof window guard in setup always keeps server and client output identical.
  • Moving the read to onBeforeMount keeps it off the server and out of the first render.
  • onUnmounted cleans up timers started in setup when the server finishes a request.