SPA

Pure client-rendered single-page app (renderMode: 'spa') — a fully interactive standalone tool, no backend, no SSR. State lives in the browser.

A pure client-rendered web app — a fully interactive standalone tool with NO backend and NO SSR. Every page sets renderMode = 'spa', so voltro build skips pre-rendering and the client renders on load. That is the right call when the page's state lives entirely in the browser (here: localStorage), so a server-rendered first paint would just be discarded on hydration. The shipped example is a bill splitter — plain React, plain CSS, no rpc client, no loader. Template id: frontend-spa.

Scaffold

voltro create-project acme --web=frontend-spa
voltro add-app splitter --template=frontend-spa --to acme

What ships

apps/acme/web/                          # dir named by the app, not the template
├── app.config.ts                       # type:web, theme:'system', defineEnv
├── package.json
├── tsconfig.json
├── README.md
└── src/
    ├── globals.css                     # self-contained plain CSS (no Tailwind / kit)
    └── pages/
        ├── layout.tsx                  # imports globals.css, renders {children}
        └── index.tsx                   # the bill splitter — renderMode: 'spa'

No rpc client, no loader, no apis: entry, no @voltro/ui-shadcn dependency. The app depends only on @voltro/web / @voltro/client / @voltro/env / @voltro/cli + React 19 — it's React plus the framework's web wiring and nothing else.

renderMode = 'spa' — client-render, no pre-render

The page exports renderMode = 'spa', which tells voltro build not to pre-render it: the client renders it on load. Use this when the whole page is interactive AND its state lives in the browser — an SSR'd first paint would just be discarded on hydration, so rendering it on the server buys nothing.

// src/pages/index.tsx — a pure client-side SPA (a bill splitter)
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'

export const renderMode = 'spa' as const
// `interactive` defaults to 'full' — every component hydrates. That is
// correct for an SPA where the whole tree is interactive (don't reach for
// islands here; islands only help when most of the page is static).

const TIP_PRESETS = [10, 15, 18, 20] as const
const STORAGE_KEY = '{{appName}}:last-tip'

export default function BillSplitter(): ReactNode {
  const [bill, setBill] = useState('')
  const [tipPct, setTipPct] = useState(18)
  const [people, setPeople] = useState(2)
  // ...
}

interactive is left at its default 'full' — the whole tree hydrates, which is right for an SPA. Islands only help when most of the page is static; here the JS is the page.

Browser-only state via localStorage

The last tip % is remembered locally. Because localStorage is client-only, it's read in an effect (the effect body never runs on the server) — restore on mount, persist on change:

// Restore the last tip % from localStorage — client-only, so read it in
// an effect (it never runs on the server).
useEffect(() => {
  const saved = Number(window.localStorage.getItem(STORAGE_KEY))
  if (Number.isFinite(saved) && saved > 0) setTipPct(saved)
}, [])

useEffect(() => {
  window.localStorage.setItem(STORAGE_KEY, String(tipPct))
}, [tipPct])

This is the canonical "spa is the right call" signal: state that only exists in the browser. With renderMode: 'static' the server would render a default tip % and the client would overwrite it on mount — a visible flash for zero benefit.

Layout + styling — self-contained, no Tailwind

The root layout.tsx imports globals.css and renders its children. The SPA renders entirely client-side, so there's no SSR document to worry about:

// src/pages/layout.tsx
import type { ReactNode } from 'react'
import '../globals.css'

export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
  return <>{children}</>
}

globals.css is hand-written plain CSS with light/dark custom properties — no @import "tailwindcss", no @source glob, no @voltro/ui-shadcn. The dark variant keys off :root.dark, which the framework's pre-paint theme script toggles (see below):

/* src/globals.css — self-contained, plain CSS */
:root {
  --bg: #f4f4f5;
  --card: #ffffff;
  --fg: #18181b;
  --muted: #71717a;
  --accent: #0d9488;
  --border: #e4e4e7;
}

:root.dark {
  --bg: #09090b;
  --card: #18181b;
  --fg: #fafafa;
  --muted: #a1a1aa;
  --accent: #2dd4bf;
  --border: #27272a;
}

Config — theme: 'system' + a public env var

app.config.ts is the minimal web shape: type: 'web', theme: 'system', and a single public env var declared with defineEnv. theme: 'system' lets the framework's inline head script set the :root.dark class from the OS preference (or the voltro:theme cookie) BEFORE first paint — no light→dark flash, and no useEffect toggling the class:

// app.config.ts
import { defineEnv, envVar } from '@voltro/env'

export const env = defineEnv({
  VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
})

export default {
  type: 'web' as const,
  name: '{{capProjectName}}{{capAppName}}',
  port: {{port}},
  theme: 'system' as const,
  env,
}

The public var is browser-bundled, so it carries the mandatory VOLTRO_PUBLIC_* prefix and is read on the client via publicEnv.VOLTRO_PUBLIC_APP_NAME from @voltro/env/public.

Build + serve

voltro build .     # production build (no pre-render for spa pages)
voltro start .     # serve the built dist/

When to use frontend-spa vs. the other web templates

You want… Pick
A heavily interactive standalone tool whose state lives in the browser frontend-spa
A blank React shell to bring your own brand frontend-blank
A marketing landing surface frontend-landing
A documentation site frontend-docs

Reach for frontend-spa only when the JS is the page. For content pages (marketing, blog, docs) prefer renderMode: 'static' and island() for the few interactive widgets — see the render modes guide.

Pairs well with

  • Nothing on the backend — the point of this template is a self-contained client app. Add an api later (and an apis: entry in app.config.ts) only when you genuinely need server-side data.
  • Any api template if you DO grow a backend — the web wiring is identical to the other web templates.

Anti-patterns

  • Reaching for interactive: 'islands' because it "sounds faster". Islands only help when most of the page is static. An SPA is interactive everywhere — every island becomes its own React root, which is slower than one full-tree hydration. Leave interactive at its default 'full'.
  • Using renderMode: 'spa' for a content page. If the page is mostly static text with a couple of interactive widgets, that's renderMode: 'static' + island(), not spa — you'd be throwing away SEO and first-paint for nothing.
  • Reading localStorage at module top-level or during render. It doesn't exist on the server and isn't synchronous-safe across hydration. Read it in a useEffect (as the template does) so the body only runs client-side.
  • Toggling the dark class from a useEffect. That paints a white flash on every navigation. The template sets theme: 'system' in app.config.ts and lets the framework's pre-paint script handle it — globals.css just reacts to :root.dark.