After drawing the SPA vs MPA line, the practical SPA default in my frontend work is still boring: Vite + React + TypeScript, no meta-framework, static deploy to a CDN, API somewhere else.
"Vanilla Vite" here means that stack, not "no React." The point is to keep the framework surface small until document rendering, file-based routing, or server mutations force a bigger machine.
What the scaffold actually buys
shpnpm create vite@latest apps/web -- --template react-ts
You get a fast dev server, ESM-native DX, esbuild-powered transforms, Rollup
production builds, and a index.html that is the real entry, not a fake file
generated by a CLI you do not understand. For an authenticated product UI with
an existing API, that is often enough.
A minimal mental model:
textindex.html → mounts #root src/main.tsx → createRoot + providers src/App.tsx → router outlet src/routes/* → screens src/lib/api.ts → fetch wrappers
I resist adding a second build pipeline "for consistency with the monorepo"
until there is a shared package that needs it. Vite already speaks workspace
packages well when you point it at @acme/ui source or built exports.
The config I keep thin
ts// vite.config.ts import path from 'node:path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true, }, }, }, });
Notes:
- Alias
@must matchtsconfigpaths. Editor and bundler disagreeing is a tax you feel on day two. - Dev proxy for cookies/CORS beats teaching every new hire about local HTTPS workarounds. Production still talks to the real API origin via env.
- Do not cargo-cult
optimizeDepsuntil a dependency actually misbehaves.
Env is boring on purpose:
ts// src/lib/env.ts const apiBase = import.meta.env.VITE_API_BASE_URL; if (!apiBase) { throw new Error('VITE_API_BASE_URL is required'); } export const env = { apiBase };
Only VITE_* keys reach the client. Anything secret stays on the server. If you
need non-public config in the browser, you do not have a Vite problem, you have
an architecture problem.
Routing without a framework religion
For small-to-medium SPAs I still start with a declarative router (React Router in the Vite app) and colocated route modules. File-based routing is nice; it is not free when you also need loaders, layouts, and SSR later.
What I optimize for early:
- URL is state for shareable screens (filters in the query string).
- Layouts that do not remount the world on every child navigate.
- Lazy route imports when the graph grows, not on hello-world day.
tsx// src/App.tsx import { lazy, Suspense } from 'react'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; const Dashboard = lazy(() => import('./routes/Dashboard')); const Settings = lazy(() => import('./routes/Settings')); export function App() { return ( <BrowserRouter> <Suspense fallback={<p>Loading…</p>}> <Routes> <Route path="/" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Routes> </Suspense> </BrowserRouter> ); }
This is intentionally unfancy. You do not need a custom router to show clear boundaries.
When vanilla Vite stops being free
I graduate toward a meta-framework (or Vite SSR) when any of these become chronic:
- Public HTML matters: marketing pages, docs, share cards, SEO. Client-only shells fight you.
- Auth gatekeeping belongs on the server for the first byte, not after a spinner and a 401.
- You want server mutations with progressive enhancement as a product requirement, not a blog aspiration.
- The BFF keeps growing inside serverless functions bolted onto a static host until you reinvent a framework poorly.
Until then, a static Vite build plus a clear API is operationally calm: preview deploys are files, rollbacks are CDN history, and local DX stays snappy.
Failure modes
Treating Vite as Next. People expect getServerSideProps ghosts. Say out
loud: this is a client SPA. Document the constraint.
Huge src/ with no package boundaries. Vanilla does not mean unstructured.
Extract @acme/ui when three screens share the same messy table primitive.
Env sprawl. Twelve VITE_* flags for feature toggles that should be server
driven. Keep the client config small enough to list on a whiteboard.
Skipping tsc because Vite built. Same lesson as the TypeScript config
post: transpile ≠ typecheck. CI still runs tsc --noEmit.
What I ship as the baseline
textapps/web # Vite + React + TS SPA index.html vite.config.ts src/main.tsx src/App.tsx packages/ui # optional shared components packages/tsconfig # shared TS base
Deploy: static assets to Pages/Netlify/S3+CDN. API: separate service. Observability: client error tracking + API metrics, not a single magical platform checkbox.
Takeaway
Vanilla Vite is the smallest SPA that still feels professional: fast DX, honest
index.html, TypeScript gated in CI, deploy as files. Add a meta-framework when
HTML, auth, or server mutations demand it, not because a starter template looked
lonely.
Next: when that SPA needs server-rendered HTML after all: Vite SSR plugins, the complexity tax, and the signals that you should have chosen a framework earlier.