Skip to content

Vanilla Vite: The Simplest SPA

A minimal Vite + React SPA that stays honest, what the scaffold buys you, how I structure routing and env, and the moment a meta-framework pays off.

5 min read
Vite
SPA
React
Frontend
Bundler
DX

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

sh
pnpm 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:

text
index.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 match tsconfig paths. 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 optimizeDeps until 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:

  1. URL is state for shareable screens (filters in the query string).
  2. Layouts that do not remount the world on every child navigate.
  3. 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

text
apps/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.