State & data layer

URL, cookies and browser persistence

The URL as state, cookies for what the server render needs, localStorage and IndexedDB without hydration mismatches, versioned schemas, cross-tab sync, export/import, and the offline basics.

Persistence questions test whether you understand the server/client boundary. The URL is visible to both and to the user; cookies are visible to both but small; browser storage is visible only to the client and only after hydration. Choosing wrong produces either hydration mismatches or a server that cannot render what the user expects. This app's own notes and progress features are a worked example of the client-only case.

Know

  • URL state for anything the user should share, bookmark or step back through: filters, sorting, pagination, selected tab or item. Read route.query and route.params, write with router.replace (no history entry) or router.push (history), parse and default with a schema because query values are strings, and keep the URL canonical (drop defaults). VueUse's useRouteQuery/useRouteParams (@vueuse/router) give two-way refs.
  • Cookies for preferences the server render must know: locale, colour mode, consent, density, and the session. useCookie works on both sides and serialises JSON; set sameSite, secure, sensible maxAge, and keep them small (4 KB each, sent on every request). Server-set cookies need refreshCookie (or the cookieStore flag) to update the client ref.
  • localStorage / sessionStorage: synchronous, string-only, ~5 MB, blocks the main thread, not available during SSR, readable by any script on the origin (never tokens). Read it after hydration (onMounted, app:suspense:resolve, or VueUse useStorage with initOnMounted: true) so the first client render matches the server's; render a neutral state until then.
  • IndexedDB: asynchronous, structured data, large quotas; use a wrapper (idb, Dexie). For big caches, offline data and anything above a few hundred kilobytes.
  • Versioned shapes: every persisted blob carries a version; loading code migrates or discards old versions. Without it, a renamed field breaks every returning user.
  • Cross-tab sync: the storage event fires in other tabs on localStorage writes; BroadcastChannel is the explicit alternative. Decide whether tabs share state or last-writer-wins.
  • Export/import turns per-device storage into something portable (this app's Settings page does exactly that); it is also the honest answer to "what if the user clears site data".
  • Offline: a service worker with the Cache API (@vite-pwa/nuxt, Workbox) caches the shell and assets; data lives in IndexedDB with a sync queue; background sync and conflict handling are the hard parts. Promise offline only for a defined subset of features.
  • Security: anything in localStorage is exposed to XSS; session tokens belong in HttpOnly cookies. Personal data in storage needs a clear-on-logout path.

How it works

Persisted client state that never causes a hydration mismatch (the pattern this app uses):

app/composables/usePersistedState.ts
export function usePersistedState<T extends object>(key: string, init: () => T) {
  // Shared per app, serialisable; on the server it holds the default and that is what renders.
  const state = useState<T>(`persisted:${key}`, init)
  const ready = useState(`persisted-ready:${key}`, () => false)

  if (import.meta.client && !ready.value) {
    const hydrate = () => {
      try {
        const raw = localStorage.getItem(`app:v1:${key}`)
        if (raw) {
          const parsed = JSON.parse(raw) as T & { version?: number }
          state.value = parsed.version === 1 ? parsed : init() // migrate or discard old shapes here
        }
      }
      catch { /* corrupt storage: keep defaults */ }
      ready.value = true
      watch(state, v => localStorage.setItem(`app:v1:${key}`, JSON.stringify(v)), { deep: true })
    }
    const nuxtApp = useNuxtApp()
    // After hydration, so the first client render matches the server HTML.
    if (nuxtApp.isHydrating) nuxtApp.hooks.hookOnce('app:suspense:resolve', hydrate)
    else hydrate()
  }

  return { state, ready }
}

A cookie preference the server can honour, and the URL-state composable with canonical query:

app/composables/useDensity.ts
export const useDensity = () => useCookie<'compact' | 'comfortable'>('density', {
  default: () => 'comfortable',
  maxAge: 60 * 60 * 24 * 365,
  sameSite: 'lax',
  secure: true,
})
app/composables/useListQuery.ts
import { z } from 'zod'

const schema = z.object({
  q: z.string().default(''),
  page: z.coerce.number().int().min(1).default(1),
  sort: z.enum(['recent', 'popular']).default('recent'),
})
type ListQuery = z.infer<typeof schema>
const defaults = schema.parse({})

export function useListQuery() {
  const route = useRoute()
  const router = useRouter()
  const query = computed<ListQuery>(() => schema.parse(route.query))

  function set(patch: Partial<ListQuery>) {
    const next = { ...query.value, ...patch }
    // Canonical: omit defaults so /list and /list?page=1&sort=recent are the same URL.
    const clean = Object.fromEntries(Object.entries(next).filter(([k, v]) => v !== defaults[k as keyof ListQuery]))
    router.replace({ query: clean })
  }

  return { query, set }
}

Cross-tab sync for a draft, and a safe read of a large cache from IndexedDB:

app/composables/useDraft.ts
export function useDraft(id: string) {
  const draft = ref('')
  if (import.meta.client) {
    const channel = new BroadcastChannel(`draft:${id}`)
    onMounted(() => { draft.value = localStorage.getItem(`draft:${id}`) ?? '' })
    watchDebounced(draft, (v) => {
      localStorage.setItem(`draft:${id}`, v)
      channel.postMessage(v)
    }, { debounce: 300 })
    channel.onmessage = (e: MessageEvent<string>) => { draft.value = e.data }
    onScopeDispose(() => channel.close())
  }
  return draft
}
Gotcha· Reading localStorage in setup

const theme = ref(localStorage.getItem('theme')) throws on the server, and guarded with import.meta.client it renders a different first frame than the server HTML: hydration mismatch. Read after hydration, or move the preference to a cookie so the server can render it.

Gotcha· Query values are strings

route.query.page is "2", or ["2", "3"] if repeated, or undefined. Parsing with a schema (z.coerce.number()) once in a composable prevents page + 1 === "21" bugs and gives every consumer typed defaults.

Exercise

Exercise
  • Implement usePersistedState, then temporarily read localStorage synchronously in setup and observe the hydration warning; restore the hook-based version.
  • Put a preference in localStorage and in a cookie; reload each and compare the first frame in a throttled network profile.
  • Open the same draft in two tabs and type in both; then remove BroadcastChannel and observe the divergence.

Be able to say

Be able to say· How do you persist state on the client without breaking server rendering?

"I first ask whether the server render needs the value. If it does, it goes in a cookie through useCookie, which both sides can read, so there is no flash and no mismatch. If it does not, it goes in browser storage, and I read it only after hydration, on app:suspense:resolve or in onMounted, rendering the default until then so the first client frame equals the server HTML. Every stored shape carries a version so I can migrate it, tabs are synced with BroadcastChannel, large data goes to IndexedDB rather than localStorage, and nothing secret goes into storage at all because any script on the origin can read it."

Be able to say· What belongs in the URL?

"Anything the user might want to share, bookmark or navigate back to: filters, sort order, page, the selected tab or item. It is the only state the server, the client and the user all see, so the server can render the right view directly and the back button works. I parse the query with a schema in one composable because values arrive as strings, default the missing ones, and write back with router.replace in canonical form, dropping defaults so equivalent views have one URL."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.