Server-Side Rendering with Vite: SSR, SSG, ISR
Modern web applications demand fast initial loads, SEO-friendly markup, and dynamic content. Vite, with its native SSR support and lightning-fast HMR, has become a popular foundation for building rendering strategies that go beyond the traditional single-page application. In this tutorial, we'll explore three rendering patterns — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — and how to implement each with Vite.
Why Rendering Strategy Matters
The choice of rendering strategy affects performance, SEO, infrastructure cost, and developer experience. A purely client-rendered app ships a blank HTML shell and hydrates content in the browser, which hurts time-to-first-byte and search indexing. Server-rendered approaches generate HTML on the server, giving users meaningful content immediately and crawlers something to read.
- SSR: HTML is generated on every request. Best for personalized, dynamic content.
- SSG: HTML is generated once at build time. Best for content that rarely changes.
- ISR: HTML is generated at build time and revalidated in the background. Best for large sites that update periodically.
Setting Up the Vite SSR Project
Let's start by creating a Vite project with SSR support. We'll use a vanilla structure so the concepts remain clear, but the same patterns apply to React, Vue, or Svelte.
npm create vite@latest vite-ssr-demo -- --template vanilla
cd vite-ssr-demo
npm install
Vite provides a low-level SSR API. The two key functions are ssrLoadModule for development and build with the ssr option for production. We'll structure our project with separate entry points for client and server.
Project Structure
vite-ssr-demo/
├── index.html
├── server.js
├── package.json
├── vite.config.js
└── src/
├── entry-client.js
├── entry-server.js
└── App.js
The Shared Application Component
We'll use a simple framework-agnostic render function that returns an HTML string. In a real app, you'd use renderToString from React or Vue.
// src/App.js
export function renderApp(path) {
const routes = {
'/': '<h1>Home</h1><p>Welcome to SSR with Vite</p>',
'/about': '<h1>About</h1><p>Built with Vite SSR</p>',
};
return routes[path] || '<h1>404</h1><p>Page not found</p>';
}
Server Entry
// src/entry-server.js
import { renderApp } from './App.js';
export function render(url) {
return renderApp(url);
}
Client Entry
// src/entry-client.js
import { renderApp } from './App.js';
const path = window.location.pathname;
const app = document.getElementById('app');
app.innerHTML = renderApp(path);
Updating index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite SSR Demo</title>
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
The <!--ssr-outlet--> comment is a placeholder that our server will replace with rendered HTML.
Implementing SSR
SSR generates HTML on every request. In development, we use Vite's middleware so we get HMR and on-the-fly transformation. In production, we build the server bundle and run it with Node.
Development Server
// server.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express';
import { createServer as createViteServer } from 'vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function createServer() {
const app = express();
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use(vite.middlewares);
app.use('*', async (req, res) => {
const url = req.originalUrl;
try {
let template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
);
template = await vite.transformIndexHtml(url, template);
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
const appHtml = render(url);
const html = template.replace('<!--ssr-outlet-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
vite.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
});
app.listen(3000, () => {
console.log('SSR server running at http://localhost:3000');
});
}
createServer();
Install Express and run the dev server:
npm install express
node server.js
Production Build
For production, we need two builds: one for the client and one for the server. Update vite.config.js:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist',
},
ssr: {
noExternal: ['express'],
},
});
Add build scripts to package.json:
{
"scripts": {
"dev": "node server.js",
"build:client": "vite build",
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
"build": "npm run build:client && npm run build:server",
"start": "NODE_ENV=production node server.js"
}
}
Then update server.js to handle both dev and production:
// server.js (production branch)
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === 'production';
async function createServer() {
const app = express();
let vite;
if (!isProduction) {
const { createServer: createViteServer } = await import('vite');
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use(vite.middlewares);
} else {
app.use(express.static(path.resolve(__dirname, 'dist/client')));
}
app.use('*', async (req, res) => {
const url = req.originalUrl;
try {
let template;
let render;
if (!isProduction) {
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
);
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule('/src/entry-server.js')).render;
} else {
template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8'
);
render = (await import('./dist/server/entry-server.js')).render;
}
const appHtml = render(url);
const html = template.replace('<!--ssr-outlet-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
if (!isProduction) vite.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
});
app.listen(3000, () => console.log('Server running at http://localhost:3000'));
}
createServer();
Implementing SSG
SSG pre-renders all routes at build time. This produces static HTML files you can deploy to any CDN. It's ideal for blogs, documentation, and marketing sites.
Static Generation Script
// generate.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { build } from 'vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const routes = ['/', '/about'];
async function generate() {
// Build client and server
await build({ build: { outDir: 'dist/client' } });
await build({
build: { ssr: 'src/entry-server.js', outDir: 'dist/server' },
});
// Load the server render function
const { render } = await import('./dist/server/entry-server.js');
// Read the built index.html template
const template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8'
);
// Generate HTML for each route
for (const route of routes) {
const appHtml = render(route);
const html = template.replace('<!--ssr-outlet-->', appHtml);
const fileName = route === '/' ? 'index.html' : `${route}/index.html`;
const outPath = path.resolve(__dirname, 'dist/client', fileName);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, html);
console.log(`Generated: ${fileName}`);
}
}
generate();
Add a script to package.json:
{
"scripts": {
"generate": "node generate.js"
}
}
Run npm run generate and you'll have a fully static site in dist/client. Deploy it to Netlify, Vercel, GitHub Pages, or any static host.
Fetching Data at Build Time
For SSG, data fetching happens during the build. Let's extend our app to fetch data from an API:
// src/App.js
export async function renderApp(path) {
const data = await fetchData(path);
switch (path) {
case '/':
return `<h1>${data.title}</h1><p>${data.body}</p>`;
case '/posts':
const posts = data
.map((p) => `<li><a href="/posts/${p.id}">${p.title}</a></li>`)
.join('');
return `<h1>Posts</h1><ul>${posts}</ul>`;
default:
return '<h1>404</h1>';
}
}
async function fetchData(path) {
if (path === '/') {
return { title: 'Home', body: 'Welcome to our SSG site' };
}
if (path === '/posts') {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
return res.json();
}
return null;
}
Update the server entry to await the async render:
// src/entry-server.js
import { renderApp } from './App.js';
export async function render(url) {
return await renderApp(url);
}
And update your generation script and SSR server to await render(url).
Implementing ISR
ISR combines the speed of SSG with the freshness of SSR. Pages are generated at build time, then revalidated in the background at a configurable interval. When a request comes in, the stale page is served immediately, and if the revalidation interval has passed, a regeneration is triggered in the background.
Vite doesn't ship ISR out of the box, but we can build it with a small cache layer. Here's a production server that implements ISR:
ISR Server Implementation
// isr-server.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REVALIDATE_SECONDS = 60; // revalidate every 60 seconds
const cache = new Map(); // route -> { html, timestamp }
async function fetchPost(id) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
return res.json();
}
async function renderPost(post) {
return `<h1>${post.title}</h1><p>${post.body}</p>`;
}
async function generatePage(route) {
const id = route.split('/').pop();
const post = await fetchPost(id);
const content = await renderPost(post);
const template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8'
);
return template.replace('<!--ssr-outlet-->', content);
}
const app = express();
app.use(express.static(path.resolve(__dirname, 'dist/client')));
app.get('/posts/:id', async (req, res) => {
const route = req.path;
const now = Date.now();
const cached = cache.get(route);
if (cached) {
const age = (now - cached.timestamp) / 1000;
// Serve stale content immediately
res.send(cached.html);
// Revalidate in background if stale
if (age > REVALIDATE_SECONDS) {
generatePage(route)
.then((html) => {
cache.set(route, { html, timestamp: Date.now() });
console.log(`Revalidated: ${route}`);
})
.catch((err) => console.error('Revalidation failed:', err));
}
return;
}
// First request: generate on the fly, cache, and serve
try {
const html = await generatePage(route);
cache.set(route, { html, timestamp: now });
res.send(html);
} catch (e) {
res.status(500).send('Error generating page');
}
});
app.listen(3000, () => console.log('ISR server at http://localhost:3000'));
How ISR Works Here
- The first request to a route generates the page on-demand and caches it.
- Subsequent requests serve the cached HTML instantly.
- When the cache is older than
REVALIDATE_SECONDS, the user still gets the stale page, but a background regeneration is triggered. - The next request after regeneration serves the fresh content.
Persisting the Cache
The in-memory cache is lost on server restart. For production, persist to disk or Redis:
import fs from 'fs';
import path from 'path';
const CACHE_DIR = path.resolve(process.cwd(), '.isr-cache');
function getCachePath(route) {
const safe = route.replace(/\//g, '_');
return path.join(CACHE_DIR, `${safe}.json`);
}
export function readCache(route) {
const p = getCachePath(route);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf-8'));
}
export function writeCache(route, data) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
fs.writeFileSync(getCachePath(route), JSON.stringify(data));
}
Best Practices
Separate Client and Server Code
Always split your entry points. Code that touches window, document, or browser APIs belongs in the client entry only. Use environment checks when sharing modules:
if (typeof window !== 'undefined') {
// browser-only code
}
Handle Hydration Carefully
If you're using React or Vue, the server-rendered HTML must match what the client renders during hydration. Mismatches cause hydration errors and performance penalties. Avoid using Date.now(), Math.random(), or any non-deterministic value during initial render.
Stream Large Responses
For data-heavy pages, use streaming to send HTML chunks as they're ready. Vite supports renderToPipeableStream in React 18, which pairs well with Vite's SSR pipeline:
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res);
},
onError(error) {
console.error(error);
},
});
});
Use a Framework When Appropriate
Vite's low-level SSR API is powerful but requires boilerplate. If your project is complex, consider frameworks built on Vite that handle SSR, SSG, and ISR out of the box:
- Vite SSG — adds SSG to Vue or React Vite apps.
- SvelteKit — full-stack framework with SSR, SSG, and ISR.
- Nuxt 3 — Vue meta-framework with hybrid rendering.
- Astro — content-focused with islands architecture.
- Remix — full-stack web framework with nested routing.
Optimize Bundle Size
Server bundles don't need to be small, but client bundles do. Use dynamic imports for client-only routes, code-split aggressively, and tree-shake server-only utilities out of the client build with ssr.noExternal and build.rollupOptions.
Cache Strategically
For ISR, choose revalidation intervals based on content volatility. A news site might revalidate every minute, while a product catalog could use hours. Combine CDN edge caching with Cache-Control: stale-while-revalidate headers for maximum performance:
res.setHeader(
'Cache-Control',
's-maxage=60, stale-while-revalidate=300'
);
Conclusion
Vite's native SSR support gives you the building blocks to implement any rendering strategy you need. SSR delivers personalized, always-fresh content; SSG provides blazing-fast static pages perfect for content sites; and ISR bridges the gap by serving cached pages while regenerating them in the background. By understanding the trade-offs of each approach and following best practices around code splitting, hydration, and caching, you can build applications that are fast, SEO-friendly, and maintainable. Start with the simplest strategy that meets your needs — often SSG — and layer in SSR or ISR only where dynamic content demands it. Vite's flexibility means you're never locked into one pattern, and you can evolve your rendering strategy as your application grows.