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 acmeWhat 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 inapp.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. Leaveinteractiveat its default'full'. - Using
renderMode: 'spa'for a content page. If the page is mostly static text with a couple of interactive widgets, that'srenderMode: 'static'+island(), notspa— you'd be throwing away SEO and first-paint for nothing. - Reading
localStorageat module top-level or during render. It doesn't exist on the server and isn't synchronous-safe across hydration. Read it in auseEffect(as the template does) so the body only runs client-side. - Toggling the
darkclass from auseEffect. That paints a white flash on every navigation. The template setstheme: 'system'inapp.config.tsand lets the framework's pre-paint script handle it —globals.cssjust reacts to:root.dark.