Skip to content

Vite + SSR Plugins: When SPAs Need Server Rendering

When a Vite SPA should grow server rendering, what SSR plugins actually own, the complexity tax, and when a meta-framework is the honest next step.

6 min read
Vite
SSR
SPA
React
Rendering
Frontend

A vanilla Vite SPA is a strong default until the first byte of HTML has to mean something: SEO, auth-aware chrome, social previews, or a first paint that is not a loading spinner over emptiness.

Server-side rendering (SSR) is how you keep the SPA architecture while letting the server produce HTML for a route. Vite supports this path, via its SSR API and ecosystem plugins/templates, but it is not a checkbox. It is a second runtime you now operate.

When SSR pays off

I add SSR when at least one of these is true and painful:

  1. Public routes need real HTML without running the full client bundle.
  2. First contentful paint on key funnels is gated on client JS + waterfalls.
  3. Share/OG previews must show title and image without a headless browser farm.
  4. Personalization on first paint (logged-in nav, locale) should not flash wrong defaults.

I do not add SSR because a talk claimed SPAs are dead. Authenticated dashboards with no public SEO story can stay CSR forever and sleep well.

What Vite SSR actually is

Roughly:

  • Dev: Vite middleware runs your server entry, transforms modules on the fly, and hydrates the same components on the client.
  • Prod: you build a client bundle and a server bundle. A Node (or compatible) server imports the server build, renders HTML, and serves assets.

A sketch of the server seam (illustrative, not a full framework):

ts
// server/index.ts import fs from 'node:fs'; import path from 'node:path'; import express from 'express'; import { createServer as createViteServer } from 'vite'; const isProd = process.env.NODE_ENV === 'production'; const app = express(); async function main() { let vite; if (!isProd) { vite = await createViteServer({ server: { middlewareMode: true }, appType: 'custom', }); app.use(vite.middlewares); } else { app.use(express.static(path.resolve('dist/client'))); } app.use('*', async (req, res, next) => { try { const url = req.originalUrl; let template = fs.readFileSync( path.resolve(isProd ? 'dist/client/index.html' : 'index.html'), 'utf-8', ); if (!isProd && vite) { template = await vite.transformIndexHtml(url, template); } const { render } = isProd ? await import('../dist/server/entry-server.js') : await vite.ssrLoadModule('/src/entry-server.tsx'); const appHtml = await render(url); const html = template.replace(`<!--app-html-->`, appHtml); res.status(200).set({ 'Content-Type': 'text/html' }).end(html); } catch (e) { if (!isProd && vite) vite.ssrFixStacktrace(e as Error); next(e); } }); app.listen(5173); } main();
tsx
// src/entry-server.tsx import { renderToString } from 'react-dom/server'; import { App } from './App'; export async function render(url: string) { // Match the client router location to `url` before render. return renderToString(<App />); }

Plugins and community templates wrap this boilerplate, streaming, renderToPipeableStream, asset collection, Cloudflare/Workers adapters. The important part is the dual entry (entry-client / entry-server) and the promise that components behave in both environments.

The complexity tax (pay it with eyes open)

SSR adds failure modes CSR never had:

TaxWhat it looks like
Dual bundlingClient and server builds drift; a Node-only import sneaks into the server graph
Hydration mismatchServer HTML ≠ first client render → React warns, UI flickers, trust dies
Request-scoped dataYou need a story for "fetch on server, reuse on client" or you double-fetch
Hosting shapeStatic CDN alone is not enough; you need a server or edge runtime
Auth on the serverCookies, headers, and redirects move into the render path

Hydration bugs are the classic tax. Anything that reads window, localStorage, or Date.now() during render will disagree across environments:

tsx
// Bad: different on server vs client function Clock() { return <span>{new Date().toLocaleTimeString()}</span>; } // Better: render stable HTML, fill in after mount function Clock() { const [time, setTime] = useState<string | null>(null); useEffect(() => { setTime(new Date().toLocaleTimeString()); }, []); return <span>{time ?? '--:--:--'}</span>; }

If your UI is full of "only in the browser" assumptions, SSR will feel like the framework is broken when your components are.

Plugins vs meta-frameworks

Vite SSR plugins/templates are right when:

  • You want to keep Vite as the center and own the server.
  • The app is mostly SPA with a few SSR routes.
  • You have someone who can debug Node middleware and dual builds.

A meta-framework (Next, Remix, React Router frameworks, etc.) pays off when:

  • Routing, data loaders, mutations, and deployment adapters are the product surface, not something you want to maintain.
  • You need conventions a team can hire into without reading your custom server.

I treat "Vite SSR as a learning project" and "Vite SSR as production platform" as different decisions. The first is healthy. The second needs an owner on-call for the render path.

Decision rule I actually use

text
CSR Vite SPA OK? Public HTML irrelevant, auth after load acceptable → stay CSR Need HTML for a handful of routes? → Consider Vite SSR / prerender for those routes only Need HTML + data + mutations as the default model? → Meta-framework; stop extending a custom server by feel

Prerender (SSG) is often the missing middle: marketing pages baked at build time, app routes still CSR. Many teams jump to full SSR when static generation would have closed the SEO ticket.

Failure modes

SSR everywhere by default. You pay dual-build cost on screens that never needed HTML. Start route-scoped.

Fetching only on the client after SSR shell. You shipped HTML of a spinner. That is not SSR; that is a facade.

No tsc on the server entry. Server modules become a second untyped island.

Edge deploy surprise. "It works on my Express box" ≠ Workers/Lambda constraints (no native Node APIs, bundle size, cold start). Pick the target host before celebrating the local middleware demo.

Takeaway

Vite SSR is a deliberate upgrade path from a CSR SPA: dual entries, hydration discipline, and a real server. Use it when HTML for the first byte matters; jump to a meta-framework when you need a full routing/data platform, not when you only needed prerender for three marketing URLs.

Q1 closes here on the SPA/MPA foundation. Astro islands and the broader React framework options follow, still trade-offs first, tribe second.