Server-Side Rendering with Jest: Testing SSR, SSG, and ISR
Modern web frameworks like Next.js, Nuxt, and Remix have popularized three distinct rendering strategies: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). Each strategy produces HTML at a different point in the application lifecycle, and each requires a different testing approach. Jest, with its rich ecosystem and snapshot capabilities, is well-suited to verify that these rendering strategies behave as expected.
This tutorial walks through what each rendering strategy is, why testing them matters, and how to write robust Jest tests for each. By the end, you will have a reusable testing pattern you can drop into any Next.js-style project.
What Are SSR, SSG, and ISR?
Server-Side Rendering (SSR)
SSR generates HTML on the server for every incoming request. The page is always fresh, which makes SSR ideal for personalized or frequently updated content such as dashboards, user profiles, or search results. The trade-off is that every request incurs server compute cost and added latency.
Static Site Generation (SSG)
SSG pre-renders HTML at build time. The output is a set of static files that can be served from a CDN. SSG is extremely fast and cheap to host, but the content is frozen at build time. It is best for blogs, marketing pages, and documentation.
Incremental Static Regeneration (ISR)
ISR is a hybrid approach. Pages are generated statically, but they can be regenerated in the background at a configurable interval (or on-demand). The first request after the revalidation window triggers a regeneration while still serving the stale page, so users never wait for a rebuild. ISR is perfect for content that updates periodically but does not need to be real-time.
Why Testing Rendering Strategies Matters
Each rendering strategy has distinct failure modes that unit tests for components alone will not catch:
- SSR bugs: using browser-only APIs like
windowordocumentduring server rendering, leaking user-specific data into cached HTML, or mishandling request headers. - SSG bugs: non-deterministic output caused by
Date.now()orMath.random()at build time, missinggetStaticPathsentries, or broken dynamic route generation. - ISR bugs: incorrect revalidation windows, stale data served indefinitely, or regeneration logic that throws and silently keeps serving broken HTML.
Jest tests that exercise the actual rendering entry points catch these issues before they reach production.
Project Setup
Assume a Next.js project. Install the testing dependencies:
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom babel-jest @babel/preset-env @babel/preset-react react-test-renderer
Create a jest.config.js at the project root:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
moduleNameMapper: {
'\\.(css|less|scss)$': '<rootDir>/__mocks__/styleMock.js',
},
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
};
Create jest.setup.js:
import '@testing-library/jest-dom';
Create __mocks__/styleMock.js:
module.exports = {};
Configure Babel in babel.config.js:
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
Testing SSR: getServerSideProps
Consider a page that fetches a user profile on every request:
// pages/profile.js
export async function getServerSideProps(context) {
const { req } = context;
const token = req.headers.authorization || null;
if (!token) {
return {
redirect: { destination: '/login', permanent: false },
};
}
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: token },
});
if (!res.ok) {
return { notFound: true };
}
const user = await res.json();
return { props: { user } };
}
export default function ProfilePage({ user }) {
return (
<main>
<h1>{user.name}</h1>
<p>{user.email}</p>
</main>
);
}
The test should verify three behaviors: redirect when no token is present, notFound when the API fails, and successful rendering when data is returned.
// __tests__/profile.test.js
import { getServerSideProps, default as ProfilePage } from '../pages/profile';
describe('ProfilePage SSR', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('redirects to /login when no authorization header is present', async () => {
const ctx = { req: { headers: {} } };
const result = await getServerSideProps(ctx);
expect(result).toEqual({
redirect: { destination: '/login', permanent: false },
});
});
it('returns notFound when the API responds with an error', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({ ok: false, status: 401 })
);
const ctx = {
req: { headers: { authorization: 'Bearer bad-token' } },
};
const result = await getServerSideProps(ctx);
expect(result).toEqual({ notFound: true });
});
it('returns user props and renders the profile', async () => {
const mockUser = { name: 'Ada Lovelace', email: 'ada@example.com' };
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(mockUser) })
);
const ctx = {
req: { headers: { authorization: 'Bearer valid-token' } },
};
const result = await getServerSideProps(ctx);
expect(result).toEqual({ props: { user: mockUser } });
expect(fetch).toHaveBeenCalledWith('https://api.example.com/me', {
headers: { Authorization: 'Bearer valid-token' },
});
// Render the component with the resolved props
const { container, getByText } = render(<ProfilePage user={mockUser} />);
expect(getByText('Ada Lovelace')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
});
Notice that the test imports getServerSideProps directly and invokes it with a mocked context. This is the key pattern: treat the data-fetching function as a pure unit and assert on its return shape, then render the component separately with the resolved props.
Testing SSG: getStaticProps and getStaticPaths
Consider a blog post page:
// pages/posts/[slug].js
export async function getStaticPaths() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
paths: posts.map((p) => ({ params: { slug: p.slug } })),
fallback: false,
};
}
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.slug}`);
const post = await res.json();
return {
props: { post },
};
}
export default function PostPage({ post }) {
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
Test both getStaticPaths and getStaticProps:
// __tests__/post.test.js
import {
getStaticPaths,
getStaticProps,
default as PostPage,
} from '../pages/posts/[slug]';
import { render } from '@testing-library/react';
describe('PostPage SSG', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('generates static paths for all posts', async () => {
const mockPosts = [
{ slug: 'hello-world' },
{ slug: 'second-post' },
];
global.fetch.mockResolvedValueOnce({
json: () => Promise.resolve(mockPosts),
});
const result = await getStaticPaths();
expect(result).toEqual({
paths: [
{ params: { slug: 'hello-world' } },
{ params: { slug: 'second-post' } },
],
fallback: false,
});
});
it('fetches a single post by slug', async () => {
const mockPost = {
title: 'Hello World',
publishedAt: '2024-01-15',
body: '<p>Welcome.</p>',
};
global.fetch.mockResolvedValueOnce({
json: () => Promise.resolve(mockPost),
});
const result = await getStaticProps({ params: { slug: 'hello-world' } });
expect(result).toEqual({ props: { post: mockPost } });
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/posts/hello-world'
);
});
it('renders the post HTML', () => {
const post = {
title: 'Hello World',
publishedAt: '2024-01-15',
body: '<p>Welcome.</p>',
};
const { container, getByText } = render(<PostPage post={post} />);
expect(getByText('Hello World')).toBeInTheDocument();
expect(container.querySelector('time')).toHaveAttribute(
'dateTime',
'2024-01-15'
);
expect(container.querySelector('article div').innerHTML).toBe(
'<p>Welcome.</p>'
);
});
});
Handling Non-Deterministic Output
SSG runs at build time, so any non-deterministic value (timestamps, random IDs) gets baked into the static HTML. To keep snapshots stable, mock the clock:
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2024-01-15T00:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
Testing ISR: Revalidation Behavior
ISR pages look identical to SSG pages, but getStaticProps returns a revalidate value. The challenge is that the actual regeneration is orchestrated by the framework, not your code. Your tests should therefore verify two things: that revalidate is set correctly, and that the data-fetching logic is idempotent and safe to re-run.
// pages/products/[id].js
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' };
}
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/products/${params.id}`);
if (!res.ok) {
return { notFound: true, revalidate: 60 };
}
const product = await res.json();
return {
props: { product },
revalidate: 60,
};
}
export default function ProductPage({ product }) {
return (
<section>
<h1>{product.name}</h1>
<p data-testid="price">${product.price.toFixed(2)}</p>
<p data-testid="stock">{product.inStock ? 'In stock' : 'Sold out'}</p>
</section>
);
}
Test the ISR contract:
// __tests__/product.test.js
import { getStaticProps, default as ProductPage } from '../pages/products/[id]';
import { render } from '@testing-library/react';
describe('ProductPage ISR', () => {
afterEach(() => jest.restoreAllMocks());
it('sets a revalidate window of 60 seconds on success', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ name: 'Widget', price: 9.99, inStock: true }),
})
);
const result = await getStaticProps({ params: { id: 'widget' } });
expect(result.revalidate).toBe(60);
expect(result.props.product).toEqual({
name: 'Widget',
price: 9.99,
inStock: true,
});
});
it('returns notFound with revalidate when the product is missing', async () => {
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 }));
const result = await getStaticProps({ params: { id: 'missing' } });
expect(result).toEqual({ notFound: true, revalidate: 60 });
});
it('is idempotent: calling getStaticProps twice yields the same shape', async () => {
const product = { name: 'Gadget', price: 19.5, inStock: false };
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(product) })
);
const first = await getStaticProps({ params: { id: 'gadget' } });
const second = await getStaticProps({ params: { id: 'gadget' } });
expect(first).toEqual(second);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('renders updated stock status after regeneration', () => {
const stale = { name: 'Gadget', price: 19.5, inStock: false };
const fresh = { name: 'Gadget', price: 19.5, inStock: true };
const { getByTestId, rerender } = render(<ProductPage product={stale} />);
expect(getByTestId('stock')).toHaveTextContent('Sold out');
rerender(<ProductPage product={fresh} />);
expect(getByTestId('stock')).toHaveTextContent('In stock');
});
});
The idempotency test is particularly valuable for ISR: because regeneration happens in the background, your getStaticProps must be safe to call repeatedly without side effects. Asserting that two calls return identical shapes (and that fetch was invoked twice, not cached) documents that contract.
Testing for Browser-Only API Leaks
A common SSR bug is referencing window or document during server rendering. Jest's default jsdom environment defines these globals, so leaks go undetected. To catch them, run a subset of tests in a Node environment where browser globals are absent.
Add a separate test file with a per-file environment override:
/**
* @jest-environment node
*/
import { getServerSideProps, default as ProfilePage } from '../pages/profile';
import React from 'react';
import { renderToString } from 'react-dom/server';
describe('ProfilePage server safety', () => {
it('does not reference window during server rendering', () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ name: 'Test', email: 't@e.com' }),
})
);
expect(() => {
renderToString(React.createElement(ProfilePage, {
user: { name: 'Test', email: 't@e.com' },
}));
}).not.toThrow();
expect(typeof window).toBe('undefined');
});
});
The @jest-environment node pragma forces Jest to run this file in a pure Node context. Any component that touches window during render will throw, and the test will fail loudly.
Best Practices
- Test the data function and the component separately. Mixing
getServerSidePropslogic with rendering logic makes failures hard to diagnose. Assert on the props shape first, then render with known props. - Mock
fetchat the global level. Avoid coupling tests to a specific HTTP library. Mockingglobal.fetchkeeps tests portable acrossnode-fetch, the native Fetch API, andaxios(with an adapter). - Use fake timers for any time-dependent output. SSG and ISR both bake data into HTML at a point in time.
jest.useFakeTimers().setSystemTime(...)guarantees reproducible snapshots. - Snapshot sparingly. Snapshots are useful for full HTML output of SSG pages, but they rot quickly for SSR pages with dynamic data. Prefer explicit assertions for dynamic content.
- Run a Node-environment suite alongside your jsdom suite. This catches browser-API leaks that jsdom would otherwise mask.
- Assert on the revalidate contract for ISR. Document the expected revalidation window in a test so that accidental changes (for example, setting
revalidate: 0and silently converting ISR to SSR) are caught in CI. - Test error and edge paths, not just the happy path. API failures, missing slugs, empty path lists, and redirect conditions are where rendering strategies most often break.
- Keep tests deterministic. Avoid relying on network order, current time, or random values. Every test should produce the same result on every run, in any CI environment.
Conclusion
SSR, SSG, and ISR each solve a different problem on the spectrum between freshness and performance, and each introduces its own class of potential bugs. By treating getServerSideProps, getStaticProps, and getStaticPaths as testable units and asserting on both their return shapes and the rendered output they produce, you can catch rendering-strategy regressions before they ship. Pair those tests with a Node-environment suite to guard against browser-API leaks, use fake timers to keep SSG and ISR output deterministic, and document your revalidation contracts explicitly. With these patterns in place, Jest becomes a reliable safety net for the most error-prone layer of any modern rendering pipeline.