← Back to DevBytes

Server-Side Rendering with Vite: SSR, SSG, ISR

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.

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

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles