Server-Side Rendering with Vitest: SSR, SSG, ISR
Server-side rendering (SSR), static site generation (SSG), and incremental static regeneration (ISR) are three powerful rendering strategies that shape how modern web applications deliver content to users. While frameworks like Next.js, Nuxt, and SvelteKit provide built-in support for these patterns, testing them reliably has historically been painful. That's where Vitest comes in — a fast, Vite-native test runner that pairs beautifully with SSR workflows because it shares the same module pipeline your application uses in production.
In this tutorial, you'll learn what SSR, SSG, and ISR are, why testing them matters, how to write meaningful tests with Vitest, and the best practices that keep your test suite fast and trustworthy.
What Are SSR, SSG, and ISR?
Server-Side Rendering (SSR)
SSR generates HTML on the server for every incoming request. The user receives a fully rendered page that the browser can paint immediately, while a client-side hydration step attaches interactivity. SSR is ideal for personalized, dynamic, or frequently changing content where caching at build time isn't feasible.
Static Site Generation (SSG)
SSG pre-renders pages at build time into static HTML files. The output is highly cacheable, cheap to host, and lightning fast. SSG works best for content that changes infrequently — marketing pages, documentation, blog posts.
Incremental Static Regeneration (ISR)
ISR is a hybrid: pages are generated statically, but they can be regenerated in the background at a configurable interval or on-demand. Users see a stale page instantly while a fresh version is built behind the scenes. ISR is the sweet spot for content that updates periodically but doesn't need to be real-time.
Why Testing Rendering Strategies Matters
Each rendering strategy introduces its own failure modes:
- SSR can break when server-only modules leak into client bundles, when async data fetching throws, or when the rendered HTML differs from what the client expects (hydration mismatches).
- SSG can silently produce stale or empty pages when data sources change shape or when build-time fetches fail.
- ISR depends on cache invalidation logic that, if buggy, serves outdated content indefinitely or regenerates too aggressively.
Vitest is well-suited for these tests because it runs in Node by default (matching your SSR runtime), supports ESM natively, and can spin up isolated module graphs per test thanks to its vi.resetModules() API. Its speed also makes it practical to run rendering tests on every commit.
Setting Up Vitest for SSR Testing
Start with a minimal project. Install Vitest and a few helpers:
npm install -D vitest @testing-library/dom jsdom
Create a vitest.config.ts that configures both a DOM environment for component tests and a Node environment for pure server tests. Vitest lets you override the environment per file, so a single config works:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node', // default for SSR/SSG/ISR tests
globals: true,
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
})
In tests/setup.ts, add a global cleanup hook so DOM-based assertions don't bleed between tests:
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/dom'
afterEach(() => {
cleanup()
})
A Minimal Render Helper
To keep examples framework-agnostic, we'll build a tiny render helper that mimics what most meta-frameworks do: it takes a component and props, renders to an HTML string on the server, and returns both the markup and a serialized state for hydration.
// src/render.ts
import { renderToString } from './server-renderer'
export interface RenderResult {
html: string
state: Record<string, unknown>
status: number
}
export async function serverRender(
component: (props: any) => Promise<string>,
props: Record<string, unknown> = {},
): Promise<RenderResult> {
try {
const html = await renderToString(component, props)
return { html, state: props, status: 200 }
} catch (error) {
return {
html: '<h1>500 Internal Error</h1>',
state: {},
status: 500,
}
}
}
Testing SSR with Vitest
SSR tests should verify three things: the server produces correct HTML, errors are handled gracefully, and the data passed to the page matches what was fetched. Here's a test file using the Node environment:
// tests/ssr.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { serverRender } from '../src/render'
import { ProductPage } from '../src/pages/ProductPage'
vi.mock('../src/api/products', () => ({
fetchProduct: vi.fn(async (id: string) => ({
id,
name: 'Vitest Sticker Pack',
price: 12.99,
inStock: true,
})),
}))
describe('SSR: ProductPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders product details into the HTML string', async () => {
const result = await serverRender(ProductPage, { id: 'sku-123' })
expect(result.status).toBe(200)
expect(result.html).toContain('Vitest Sticker Pack')
expect(result.html).toContain('$12.99')
expect(result.html).toContain('In stock')
})
it('serializes state for client hydration', async () => {
const result = await serverRender(ProductPage, { id: 'sku-123' })
expect(result.state).toMatchObject({ id: 'sku-123' })
})
it('returns a 500 page when the API throws', async () => {
const { fetchProduct } = await import('../src/api/products')
vi.mocked(fetchProduct).mockRejectedValueOnce(new Error('API down'))
const result = await serverRender(ProductPage, { id: 'broken' })
expect(result.status).toBe(500)
expect(result.html).toContain('500 Internal Error')
})
})
Notice how vi.mock replaces the data layer. In SSR tests you almost always want to mock network calls so the suite stays deterministic and fast. The third test demonstrates that error paths are first-class citizens — a 500 response should be a deliberate, tested outcome, not an unhandled crash.
Testing SSG with Vitest
SSG tests focus on build-time output: given a list of routes and a data source, does the generator produce the expected static files? Let's imagine a simple SSG function:
// src/ssg.ts
import { writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { serverRender } from './render'
import { BlogPostPage } from './pages/BlogPostPage'
import { listPosts } from './api/posts'
export interface SSGReport {
routes: string[]
outputDir: string
}
export async function generateStaticSite(outputDir: string): Promise<SSGReport> {
const posts = await listPosts()
const routes: string[] = []
await mkdir(join(outputDir, 'posts'), { recursive: true })
for (const post of posts) {
const { html } = await serverRender(BlogPostPage, { slug: post.slug })
const filePath = join(outputDir, 'posts', `${post.slug}.html`)
await writeFile(filePath, html)
routes.push(`/posts/${post.slug}`)
}
return { routes, outputDir }
}
Now test it against a temporary directory and a mocked data source:
// tests/ssg.test.ts
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
import { rm, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateStaticSite } from '../src/ssg'
vi.mock('../src/api/posts', () => ({
listPosts: vi.fn(async () => [
{ slug: 'hello-vitest', title: 'Hello Vitest' },
{ slug: 'ssr-deep-dive', title: 'SSR Deep Dive' },
]),
}))
describe('SSG: static site generation', () => {
const outputDir = join(tmpdir(), `ssg-test-${Date.now()}`)
afterAll(async () => {
await rm(outputDir, { recursive: true, force: true })
})
it('writes one HTML file per post', async () => {
const report = await generateStaticSite(outputDir)
expect(report.routes).toEqual([
'/posts/hello-vitest',
'/posts/ssr-deep-dive',
])
const file = await readFile(join(outputDir, 'posts', 'hello-vitest.html'), 'utf8')
expect(file).toContain('Hello Vitest')
})
it('is idempotent across runs', async () => {
const first = await generateStaticSite(outputDir)
const second = await generateStaticSite(outputDir)
expect(first.routes).toEqual(second.routes)
})
})
Using os.tmpdir() keeps tests hermetic — no pollution of your project tree. The idempotency test is a subtle but important one: SSG runs repeatedly in CI, and a generator that appends rather than overwrites will silently corrupt output over time.
Testing ISR with Vitest
ISR adds a cache layer with a revalidation window. The trick to testing ISR is simulating time. Vitest's vi.useFakeTimers() lets you advance the clock without waiting. Here's a minimal ISR cache implementation:
// src/isr.ts
export interface CacheEntry {
html: string
generatedAt: number
}
export class ISRCache {
private store = new Map<string, CacheEntry>()
private regenerating = new Set<string>()
constructor(
private ttlMs: number,
private renderer: (key: string) => Promise<string>,
) {}
async get(key: string): Promise<CacheEntry> {
const entry = this.store.get(key)
if (!entry) {
const html = await this.renderer(key)
const fresh = { html, generatedAt: Date.now() }
this.store.set(key, fresh)
return fresh
}
const age = Date.now() - entry.generatedAt
if (age > this.ttlMs && !this.regenerating.has(key)) {
// Serve stale, regenerate in background
this.regenerating.add(key)
this.renderer(key)
.then((html) => {
this.store.set(key, { html, generatedAt: Date.now() })
})
.finally(() => this.regenerating.delete(key))
}
return entry
}
peek(key: string): CacheEntry | undefined {
return this.store.get(key)
}
}
And the corresponding tests:
// tests/isr.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ISRCache } from '../src/isr'
describe('ISR: cache with revalidation', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('renders fresh content on first request', async () => {
const renderer = vi.fn(async (key: string) => `<h1>${key} v1</h1>`)
const cache = new ISRCache(60_000, renderer)
const entry = await cache.get('home')
expect(entry.html).toBe('<h1>home v1</h1>')
expect(renderer).toHaveBeenCalledTimes(1)
})
it('serves cached content within TTL', async () => {
const renderer = vi.fn(async (key: string) => `<h1>${key} v1</h1>`)
const cache = new ISRCache(60_000, renderer)
await cache.get('home')
vi.advanceTimersByTime(30_000)
const entry = await cache.get('home')
expect(entry.html).toBe('<h1>home v1</h1>')
expect(renderer).toHaveBeenCalledTimes(1) // no re-render yet
})
it('serves stale content while regenerating after TTL', async () => {
let version = 1
const renderer = vi.fn(async (key: string) => {
// simulate async work
await new Promise((r) => setTimeout(r, 100))
return `<h1>${key} v${version++}</h1>`
})
const cache = new ISRCache(60_000, renderer)
await cache.get('home')
vi.advanceTimersByTime(70_000)
// Trigger revalidation; should still return stale immediately
const stale = await cache.get('home')
expect(stale.html).toBe('<h1>home v1</h1>')
// Allow background regeneration to complete
await vi.advanceTimersByTimeAsync(200)
const fresh = cache.peek('home')
expect(fresh?.html).toBe('<h1>home v2</h1>')
})
})
These tests capture the defining behavior of ISR: instant stale-while-revalidate responses, background regeneration, and eventual consistency. Without fake timers, this suite would take minutes to run; with them, it finishes in milliseconds.
Testing Hydration Mismatches
One of the most common SSR bugs is a hydration mismatch — the server renders one thing, the client renders another, and React (or your framework of choice) warns or throws. You can catch these by rendering the same component on both sides and diffing the output:
// tests/hydration.test.ts
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import { renderToString } from '../src/server-renderer'
import { render } from '../src/client-renderer'
import { UserProfile } from '../src/pages/UserProfile'
describe('Hydration parity', () => {
it('produces identical markup on server and client', async () => {
const props = { user: { name: 'Ada', joined: '2024-01-15' } }
const serverHtml = await renderToString(UserProfile, props)
const clientHtml = render(UserProfile, props)
// Normalize whitespace before comparing
const normalize = (s: string) => s.replace(/\s+/g, ' ').trim()
expect(normalize(clientHtml)).toBe(normalize(serverHtml))
})
})
The @vitest-environment jsdom pragma at the top of the file overrides the default Node environment for that single test, giving you a DOM for the client renderer while the server renderer still runs in Node.
Best Practices
Mock at the Boundary, Not Inside
Mock network calls, database queries, and filesystem access — the edges of your system. Avoid mocking your own components or render helpers; you want those to run for real so the tests catch regressions in them.
Reset Modules Between Stateful Tests
ISR caches and SSG generators hold state. Use vi.resetModules() in beforeEach when a test needs a fresh instance, otherwise state leaks across cases and produces flaky failures.
Keep Rendering Tests Synchronous Where Possible
Async rendering is unavoidable in SSR, but prefer deterministic timers and mocked I/O over real network calls. A rendering test that hits a live API is a slow, brittle test.
Test Error Paths Explicitly
SSR errors surface as 500s to real users. Write tests that force failures in data fetching, component rendering, and serialization, then assert the user-visible outcome (status code, fallback markup, error boundary output).
Snapshot Sparingly
HTML snapshots are tempting but noisy. Prefer targeted assertions like toContain or toMatchObject on specific content. If you do snapshot, scope it to small fragments and review diffs carefully.
Run SSR Tests in CI with the Production Build
Vitest can run against your production bundle by configuring resolve.alias or by importing from dist/. This catches issues that only appear after minification and tree-shaking, such as dead-code elimination removing a server-only utility.
Conclusion
SSR, SSG, and ISR each solve a different problem on the spectrum of freshness versus performance, and each carries its own testing concerns. Vitest's Vite-native architecture, fast cold starts, fake timers, and flexible per-file environments make it an excellent fit for rendering tests — you can verify server output, build-time generation, cache revalidation, and hydration parity all in one suite that runs in seconds. By mocking at the boundaries, testing error paths as deliberately as happy paths, and using fake timers to tame time-dependent ISR logic, you build a safety net that catches the regressions most likely to reach production: stale pages, broken hydration, and silent 500s. Treat your rendering strategies as first-class tested code, and your users will feel the difference in every page load.