← Back to DevBytes

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

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles