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:
- SSR generates HTML on every request, so the response should reflect the latest data.
- SSG generates HTML at build time, so the response is identical until the next build.
- ISR serves a cached page and regenerates it in the background after a revalidation window, meaning the first and second request may return different HTML.
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
- Test raw HTML separately from hydration. Use
cy.task()with a Node-side fetch to inspect server output, then usecy.visit()to assert the hydrated DOM matches. This catches hydration mismatches early. - Run SSG and ISR tests against a production build. Dev mode renders on demand, which masks caching behavior. Use
next build && next startfor accurate results. - Keep revalidate windows short in test environments. A 5-second revalidate is testable; a 60-minute one is not. Use environment variables to override the revalidate value during E2E runs.
- Stub external APIs with
cy.intercept()for deterministic data. For SSR and ISR tests where you need predictable values, intercept the upstream API and return fixture data. - Avoid relying on wall-clock time. When testing SSR timestamp pages, compare two requests rather than asserting an exact value.
- Use
cy.request()for API routes andcy.task()for raw HTML. The former is simpler for JSON endpoints; the latter is necessary when you need the full HTML body and headers. - Isolate rendering-strategy tests in separate spec files. SSR, SSG, and ISR have different timing requirements and cache behaviors. Mixing them in one spec can produce flaky results.
- Assert on cache headers. Headers like
cache-controlandx-nextjs-cacheare part of the contract. Testing them prevents regressions when infrastructure changes.
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.