← Back to DevBytes

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

Server-Side Rendering with Cypress: Testing SSR, SSG, and ISR

Modern web frameworks like Next.js, Nuxt, and Remix have popularized multiple 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 request lifecycle, which makes end-to-end testing more nuanced than testing a traditional client-side app. Cypress, while best known for running in the browser, can be extended to validate server-rendered output, inspect response payloads, and assert on the timing of regeneration.

This tutorial walks through what each rendering strategy means from a testing perspective, how to configure Cypress to inspect server output, and how to write robust tests for SSR, SSG, and ISR in a Next.js-style application.

Why Rendering Strategy Matters for Testing

A common misconception is that Cypress only sees the final DOM after JavaScript hydration. While that is the default behavior, Cypress also exposes cy.request(), cy.intercept(), and Node-based task plugins that let you inspect the raw HTML returned by the server. This matters because:

If your tests only assert against the hydrated DOM, you may miss bugs where the server-rendered HTML differs from what the client renders — a classic hydration mismatch.

Project Setup

Assume a Next.js project with three pages: /ssr, /ssg, and /isr. Install Cypress and a small server plugin that lets you fetch raw HTML from within tests.

npm install --save-dev cypress @cypress/code-coverage start-server-and-test

Create a cypress.config.ts file with a custom task that performs a raw HTTP request from the Node side. This lets tests inspect server output without going through the browser.

import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    setupNodeEvents(on, config) {
      on("task", {
        async fetchRaw({ url }: { url: string }) {
          const res = await fetch(url);
          const html = await res.text();
          return { status: res.status, html, headers: Object.fromEntries(res.headers) };
        },
      });
      return config;
    },
  },
});

Run the dev server and Cypress together using start-server-and-test:

{
  "scripts": {
    "dev": "next dev",
    "cypress:run": "start-server-and-test dev http://localhost:3000 'cypress run'"
  }
}

Testing Server-Side Rendering (SSR)

SSR renders HTML on every request. The defining test property is that two sequential requests should reflect server-side data changes. Consider a page that reads the current time from a database or API on each request.

// pages/ssr.tsx (Next.js)
export async function getServerSideProps() {
  const res = await fetch("https://api.example.com/now");
  const { timestamp } = await res.json();
  return { props: { timestamp } };
}

export default function Page({ timestamp }: { timestamp: string }) {
  return <main data-testid="ssr-root">Server time: {timestamp}</main>;
}

The Cypress test should verify two things: that the raw HTML contains the timestamp before hydration, and that the hydrated DOM matches the server output.

// cypress/e2e/ssr.cy.ts
describe("SSR page", () => {
  it("returns server-rendered HTML with dynamic data", () => {
    cy.task("fetchRaw", { url: "http://localhost:3000/ssr" }).then(
      (result: any) => {
        expect(result.status).to.eq(200);
        expect(result.html).to.include("Server time:");
        // Extract the timestamp from raw HTML
        const match = result.html.match(/Server time: (.+?)</);
        expect(match, "timestamp present in raw HTML").to.not.be.null;
      }
    );
  });

  it("hydrates without mismatching server output", () => {
    cy.task("fetchRaw", { url: "http://localhost:3000/ssr" }).then(
      (raw: any) => {
        const serverText = raw.html.match(/Server time: (.+?)</)[1];
        cy.visit("/ssr");
        cy.get("[data-testid='ssr-root']").should(
          "contain",
          `Server time: ${serverText}`
        );
      }
    );
  });

  it("reflects new data on subsequent requests", () => {
    cy.task("fetchRaw", { url: "http://localhost:3000/ssr" }).as("first");
    cy.wait(1100); // ensure timestamp changes
    cy.task("fetchRaw", { url: "http://localhost:3000/ssr" }).as("second");

    cy.get("@first").then((first: any) => {
      cy.get("@second").then((second: any) => {
        const t1 = first.html.match(/Server time: (.+?)</)[1];
        const t2 = second.html.match(/Server time: (.+?)</)[1];
        expect(t1).to.not.eq(t2);
      });
    });
  });
});

Asserting on Response Headers

SSR pages often set cache-control headers. Use the custom task to verify them.

it("sets no-cache headers for SSR", () => {
  cy.task("fetchRaw", { url: "http://localhost:3000/ssr" }).then(
    (result: any) => {
      const cacheControl = result.headers["cache-control"] || "";
      expect(cacheControl.toLowerCase()).to.include("no-store");
    }
  );
});

Testing Static Site Generation (SSG)

SSG pages are generated once at build time. The defining test property is that the HTML is stable across requests within a build. In dev mode, Next.js still renders on demand, so to truly test SSG you should run against a production build.

// pages/ssg.tsx
export async function getStaticProps() {
  const res = await fetch("https://api.example.com/posts/1");
  const post = await res.json();
  return { props: { post }, revalidate: false };
}

export default function Page({ post }: { post: { title: string } }) {
  return <main data-testid="ssg-root">{post.title}</main>;
}

Update your scripts to test against a production build:

{
  "scripts": {
    "build": "next build",
    "start": "next start",
    "cypress:prod": "start-server-and-test start http://localhost:3000 'cypress run'"
  }
}

The SSG test asserts that two requests return byte-identical HTML for the same page.

// cypress/e2e/ssg.cy.ts
describe("SSG page", () => {
  it("returns identical HTML across requests", () => {
    cy.task("fetchRaw", { url: "http://localhost:3000/ssg" }).as("first");
    cy.task("fetchRaw", { url: "http://localhost:3000/ssg" }).as("second");

    cy.get("@first").then((first: any) => {
      cy.get("@second").then((second: any) => {
        expect(first.html).to.eq(second.html);
      });
    });
  });

  it("contains build-time content in raw HTML", () => {
    cy.task("fetchRaw", { url: "http://localhost:3000/ssg" }).then(
      (result: any) => {
        expect(result.html).to.include("data-testid=\"ssg-root\"");
      }
    );
  });
});

Testing Incremental Static Regeneration (ISR)

ISR serves a cached static page and regenerates it in the background after a revalidation period. The defining test property is that the first request returns the cached version, and after the revalidate window plus a second request, the HTML reflects updated data.

// pages/isr.tsx
export async function getStaticProps() {
  const res = await fetch("https://api.example.com/counter");
  const { count } = await res.json();
  return { props: { count }, revalidate: 5 };
}

export default function Page({ count }: { count: number }) {
  return <main data-testid="isr-root">Count: {count}</main>;
}

Testing ISR requires controlling time and triggering regeneration. The test below captures the initial count, waits longer than the revalidate window, makes a second request to trigger regeneration, then a third request to read the regenerated HTML.

// cypress/e2e/isr.cy.ts
function extractCount(html: string): number {
  const match = html.match(/Count: (\d+)/);
  return match ? parseInt(match[1], 10) : NaN;
}

describe("ISR page", () => {
  const REVALIDATE_MS = 5000;

  it("serves stale content then regenerates after revalidate window", () => {
    // 1. Capture the initial cached value
    cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
      (first: any) => {
        const initialCount = extractCount(first.html);
        expect(initialCount).to.be.a("number");

        // 2. Wait beyond the revalidate window
        cy.wait(REVALIDATE_MS + 1000);

        // 3. Second request triggers background regeneration but
        //    still returns the stale cached HTML
        cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
          (second: any) => {
            expect(extractCount(second.html)).to.eq(initialCount);

            // 4. Allow regeneration to complete
            cy.wait(1500);

            // 5. Third request should now return the regenerated HTML
            cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
              (third: any) => {
                const newCount = extractCount(third.html);
                expect(newCount).to.be.a("number");
                // The count should reflect a fresh fetch
                expect(newCount).to.be.gte(initialCount);
              }
            );
          }
        );
      }
    );
  });

  it("includes the x-nextjs-cache header indicating ISR", () => {
    cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
      (result: any) => {
        const cacheHeader = result.headers["x-nextjs-cache"] || "";
        expect(["HIT", "STALE", "MISS", "REVALIDATED"]).to.include(
          cacheHeader.toUpperCase()
        );
      }
    );
  });
});

On-Demand Revalidation

Many ISR setups expose an API route that triggers revalidation via res.revalidate(path). You can test this end-to-end by hitting the webhook and then asserting the next request returns fresh content.

it("regenerates on demand via revalidate API", () => {
  cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
    (before: any) => {
      const beforeCount = extractCount(before.html);

      // Trigger on-demand revalidation
      cy.request("POST", "/api/revalidate", { path: "/isr" }).then(
        (res) => {
          expect(res.body.revalidated).to.eq(true);
        }
      );

      cy.wait(1000);

      cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
        (after: any) => {
          const afterCount = extractCount(after.html);
          expect(afterCount).to.not.eq(beforeCount);
        }
      );
    }
  );
});

Best Practices

Handling Flakiness in ISR Tests

ISR tests are inherently timing-sensitive. To reduce flakiness, wrap regeneration waits in retries using cy.waitUntil from the cypress-wait-until plugin, or poll the page until the count changes.

import "cypress-wait-until";

it("polls until regenerated content appears", () => {
  cy.task("fetchRaw", { url: "http://localhost:3000/isr" }).then(
    (initial: any) => {
      const initialCount = extractCount(initial.html);
      cy.request("POST", "/api/revalidate", { path: "/isr" });

      cy.waitUntil(
        () =>
          cy
            .task("fetchRaw", { url: "http://localhost:3000/isr" })
            .then((r: any) => extractCount(r.html) !== initialCount),
        { timeout: 10000, interval: 500 }
      );
    }
  );
});

Conclusion

Testing server-rendered output with Cypress requires looking beyond the hydrated DOM. By combining cy.visit() for browser-side assertions with cy.task() and cy.request() for raw server output, you can validate the full contract of SSR, SSG, and ISR: dynamic freshness for SSR, build-time stability for SSG, and revalidation behavior for ISR. Running these tests against production builds, asserting on cache headers, and isolating each rendering strategy in its own spec will give you confidence that your rendering pipeline behaves correctly under real conditions. As frameworks continue to blur the line between server and client, treating the server response as a first-class test target is one of the most valuable investments you can make in your end-to-end suite.

— Ad —

Google AdSense will appear here after approval

← Back to all articles