Building Nuxt apps

State and runtime config

useState for SSR-safe shared state, what the payload can serialise, runtimeConfig versus app.config versus environment variables, useCookie, and when a store is the right tool.

State in a server-rendered app has an extra dimension: it must survive the trip from the server render to the client without leaking between users. "Where does this value live" is therefore a design question with four candidate answers (component, useState, store, server), and "how do I configure this per environment" has three (runtimeConfig, app.config, build-time constants). Interviewers ask about all of them.

Know

  • useState(key, init) is an SSR-friendly ref shared across components by key: created once per request on the server, serialised into the payload, and picked up by the client, so init does not run twice and the server and client agree. Keys are global, so namespace them ('cart:items'). clearNuxtState(key) resets. It is the right tool for cross-component state that is small and serialisable; it is not a store with actions.
  • Serialisation limits. The payload is encoded with devalue: plain objects, arrays, Date, Map, Set, RegExp, BigInt, undefined and NaN round-trip. Class instances, functions and DOM nodes do not, unless you register a definePayloadReducer/definePayloadReviver pair in a plugin. Anything you put in useState or return from useAsyncData must obey this.
  • Module-level variables are per process, not per request. let currentUser at the top of a composable is shared by every request the server handles. Per-request state goes through useState, useNuxtApp() context, or event.context on the server side.
  • runtimeConfig is for values that differ per environment: private keys at the top level are server-only, public is exposed to the client. Every value needs a default in nuxt.config.ts so its type and shape are known; at runtime, environment variables override them by name (NUXT_API_SECRET, NUXT_PUBLIC_API_BASE), and the string is coerced to the default's type. .env files are read in dev and at build, but in production the variables must exist in the process environment, because Nitro reads them when the server starts.
  • app.config.ts is reactive, public, build-time configuration for things like theme tokens or feature labels; it cannot be overridden by environment variables and must not hold secrets. useAppConfig() reads it, updateAppConfig() changes it at runtime, and layers merge it with a custom array-aware merger.
  • useCookie(name, options) is a ref backed by a cookie that works on both sides: on the server it reads the request cookie and sets Set-Cookie, on the client it reads and writes document.cookie. Options cover maxAge, sameSite, secure, httpOnly (server-only then), default, encode/decode, watch. It is the standard way to carry a preference the server render needs (locale, colour mode, consent), and refreshCookie(name) resyncs after an API sets it.
  • Where a store fits. Pinia gives you actions, getters, devtools and plugins on top of per-request state; @pinia/nuxt hydrates the store from the payload the same way useState does. Reach for it when state has behaviour or is read across many pages; keep useState for small shared values. The state and data section goes deeper.
  • useNuxtApp() exposes the per-request app: $config, payload, ssrContext (server), hook, provide, isHydrating, runWithContext for calling composables from outside the Vue instance context.

How it works

Shared state and its initialiser, once per request:

app/composables/useCart.ts
interface Cart { items: { id: string, qty: number }[], updatedAt?: string }

export function useCart() {
  // One ref per request on the server, one per app on the client, keyed globally.
  const cart = useState<Cart>('cart', () => ({ items: [] }))

  function add(id: string, qty = 1) {
    const item = cart.value.items.find(i => i.id === id)
    if (item) item.qty += qty
    else cart.value.items.push({ id, qty })
    cart.value.updatedAt = new Date().toISOString()
  }

  const count = computed(() => cart.value.items.reduce((n, i) => n + i.qty, 0))
  return { cart, count, add }
}

Runtime config with private and public halves, overridden by environment at runtime:

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // server-only; NUXT_STRIPE_SECRET overrides it in production
    stripeSecret: '',
    session: { maxAge: 60 * 60 * 24 * 7 }, // NUXT_SESSION_MAX_AGE, coerced to number
    public: {
      apiBase: '/api', // NUXT_PUBLIC_API_BASE
      posthogKey: '', // NUXT_PUBLIC_POSTHOG_KEY
    },
  },
})
server/api/checkout.post.ts
export default defineEventHandler((event) => {
  const { stripeSecret, public: { apiBase } } = useRuntimeConfig(event)
  if (!stripeSecret) throw createError({ status: 500, statusText: 'Payment provider not configured' })
  // ...
})

A cookie-backed preference the server render can honour, so there is no flash on load:

app/composables/useDensity.ts
export function useDensity() {
  return useCookie<'compact' | 'comfortable'>('ui-density', {
    default: () => 'comfortable',
    maxAge: 60 * 60 * 24 * 365,
    sameSite: 'lax',
  })
}

Teaching the payload a class, when you genuinely need one:

app/plugins/payload.ts
import { Money } from '#shared/utils/money'

export default definePayloadPlugin(() => {
  definePayloadReducer('Money', value => value instanceof Money && { amount: value.amount, currency: value.currency })
  definePayloadReviver('Money', ({ amount, currency }) => new Money(amount, currency))
})
Gotcha· A secret in runtimeConfig.public

Everything under public is serialised into the HTML for every visitor. API keys for third parties that must stay secret go in the private half and are used from server routes only; the client calls your route, never the third party.

Gotcha· Build-time versus runtime

process.env.API_URL baked into a client bundle is frozen at build time; the same Docker image cannot serve staging and production. runtimeConfig is read when the server starts, so one image works everywhere. This is the reason the NUXT_* convention exists.

Exercise

Exercise
  • Put a counter in a module-level ref inside a composable, render it in a page, and reload in two browsers while incrementing in one. Watch the server-rendered number leak. Fix it with useState.
  • Add runtimeConfig with a private and a public value, build, and start the server with NUXT_PUBLIC_API_BASE=https://staging.example.com node .output/server/index.mjs. Confirm the override in the client and that the private value never appears in view-source.
  • Store a theme preference in useCookie, set it on the client, reload, and confirm the server HTML already has the right class.

Be able to say

Be able to say· Where does state live in a Nuxt app?

"Four places, and the choice is about scope and lifetime. Component-local state is a ref in setup. State shared across components in one page view is useState: created once per request on the server, serialised into the payload, picked up on the client, and keyed globally. State with behaviour that many pages read, the current user, a cart, filters, is a Pinia store, which hydrates the same way. Anything that must survive a reload or that the server render needs to know, locale, colour mode, consent, goes in a cookie through useCookie. What never works is a module-level variable, because on the server that is shared by every request."

Be able to say· runtimeConfig or app.config?

"runtimeConfig is for anything that changes between environments or must stay secret: it has a server-only half and a public half, each key needs a default so the shape is typed, and environment variables named NUXT_… override the values when the server starts, so one build runs everywhere. app.config is public, reactive, build-time configuration such as theme tokens or labels, readable with useAppConfig, overridable at runtime in code but not through the environment, and merged across layers. The test: if it is a secret or differs per deployment it is runtime config; if it is a design decision it is app config."

Your notes

Open in Notes

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