ScreenshotNeo

BlogHow-to

How to Capture Screenshots of Every Route in a Website for Visual Regression Testing

Build a route inventory, capture stable Playwright baselines, and compare every chosen route and state in CI without noisy visual diffs.

By the ScreenshotNeo team4 October 202612 min read

To capture screenshots of every route for visual regression testing, first create an explicit, normalized list of routes, then run a Playwright Test for each route with deterministic data, a known viewport, and a clear readiness condition. Use expect(page).toHaveScreenshot() to create reference images on the first run and compare later runs. A screenshot tool does not discover every route for you: coverage means the routes, viewports, and page states your test actually reaches.

This guide uses TypeScript and Playwright Test. It covers the route manifest, runnable test setup, authentication and state, discovery options, baseline review, CI, and how to reduce visual noise without hiding defects.

1. Decide what “every route” means

Start with the application’s route configuration. A route is not necessarily a single URL: localized paths, query parameters, tenant identifiers, and dynamic IDs can multiply the set. A route also does not imply all its UI states. A page with a menu, modal, empty state, and populated state needs explicit cases for whichever states matter to the visual contract.

Coverage dimension Decision to make Example
Route source Which routes count as supported? Router configuration, maintained manifest
Normalization How are equivalent URLs represented? Trailing slash, host, case, locale prefix
Query strings Which parameters create distinct pages? Keep ?tab=billing; discard tracking IDs
Parameters Which concrete records need visual coverage? One representative product, plus special edge records
Access and state What fixture, login, or seeded data is required? Signed-in project page with stable test data
Viewport and interaction Which responsive layouts and states matter? Desktop and mobile; menu open as a separate case

Do not claim complete route coverage from a sitemap or a link crawl alone. Those methods can miss unlinked pages, authenticated sections, client-only routes, and parameterized routes. Use them as supplements to the route configuration, then inspect the final manifest.

2. Install Playwright Test and define a route manifest

In a Node.js project, install Playwright Test and its browser. This example assumes the site is available at http://127.0.0.1:3000 and has a test data setup that makes the listed pages predictable.

npm init playwright@latest
npx playwright install chromium

Create visual-routes.ts at the project root. Keep routes relative to one configured base URL so a staging host change does not require editing each entry.

export type VisualRoute = {
  id: string;
  path: string;
  readySelector: string;
  // Use a separate authenticated project or setup fixture where needed.
  auth?: "public" | "user";
};

export const routes: VisualRoute[] = [
  { id: "home", path: "/", readySelector: "main" },
  { id: "pricing", path: "/pricing", readySelector: "main" },
  { id: "docs", path: "/docs/getting-started", readySelector: "main" },
  { id: "account", path: "/account", readySelector: "[data-testid='account-page']", auth: "user" },
];

// Normalize and reject duplicates before tests run. Query strings are preserved
// here; choose a policy that matches your application.
const normalize = (path: string) => {
  const url = new URL(path, "http://route-manifest.invalid");
  if (url.pathname !== "/") url.pathname = url.pathname.replace(/\/+$/, "");
  return `${url.pathname}${url.search}`;
};

const seen = new Set<string>();
for (const route of routes) {
  if (!route.path.startsWith("/")) throw new Error(`Route must start with /: ${route.path}`);
  const key = normalize(route.path);
  if (seen.has(key)) throw new Error(`Duplicate visual route: ${key}`);
  seen.add(key);
}

For dynamic routes, use representative fixtures rather than trying to render every possible identifier. Add separate cases for records that exercise materially different layouts: an unusually long title, empty collection, missing image, or validation error. Keep route IDs stable and human-readable because they become screenshot names.

3. Configure a stable browser and base URL

Set the browser project, base URL, and test timeout in playwright.config.ts. Use a consistent operating system, browser version, fonts, and headless setting for both baseline creation and comparison. Playwright documents that rendering can vary with the host OS, browser version and settings, hardware, power source, and headless mode; align the baseline and comparison environment. See Playwright visual comparisons.

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  timeout: 30_000,
  expect: {
    timeout: 8_000,
    toHaveScreenshot: {
      animations: "disabled",
      // Keep strict defaults initially. Set thresholds only after reviewing
      // actual, acceptable rendering variation.
    },
  },
  use: {
    baseURL: process.env.BASE_URL ?? "http://127.0.0.1:3000",
    browserName: "chromium",
    viewport: { width: 1440, height: 900 },
    colorScheme: "light",
    locale: "en-US",
    timezoneId: "UTC",
    headless: true,
    trace: "retain-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  ],
  webServer: {
    command: "npm run dev -- --host 127.0.0.1",
    url: "http://127.0.0.1:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Pin your Playwright package version in the lockfile and install the matching browser in CI. If you deliberately compare another browser or platform, treat it as its own baseline project. A Chromium baseline is not automatically a Firefox or WebKit baseline.

4. Test every manifest entry with a visual assertion

Create tests/visual-routes.spec.ts. The core loop opens each route, checks that the server returned a successful document response, waits for an application-specific ready element, and compares a screenshot. Playwright’s toHaveScreenshot() captures repeatedly until two consecutive screenshots match before making the comparison; it cannot determine whether your app has finished loading the right data, so the explicit readiness check is still important. See Playwright assertions.

import { test, expect } from "@playwright/test";
import { routes } from "../visual-routes";

test.describe("route visual regression", () => {
  for (const route of routes) {
    test(`${route.id} (${route.path})`, async ({ page, baseURL }) => {
      // This simple example covers public routes. For private routes, use
      // an authenticated project or a fixture that creates the required state.
      const response = await page.goto(route.path, { waitUntil: "domcontentloaded" });
      expect(response, `No document response for ${route.path}`).toBeTruthy();
      expect(response!.ok(), `HTTP ${response!.status()} at ${route.path}`).toBeTruthy();

      await expect(page.locator(route.readySelector)).toBeVisible();
      await expect(page).toHaveScreenshot(`${route.id}.png`, {
        fullPage: true,
        animations: "disabled",
      });
    });
  }
});

Run the tests once to create reference snapshots, inspect the images, and commit approved files alongside the tests. On later runs, Playwright compares the actual screenshots with those references. A failing test produces comparison artifacts for diagnosis.

npx playwright test tests/visual-routes.spec.ts
npx playwright show-report

Only accept a new baseline when the visual change is intentional. Review the changed page and the diff, then update snapshots deliberately:

npx playwright test tests/visual-routes.spec.ts --update-snapshots

5. Set up authenticated and data-dependent routes

A route that requires authentication must be captured in the intended signed-in state, not as a login redirect. Prefer a dedicated test account and stable seeded data. Playwright supports saving authenticated browser state and reusing it; keep state files out of version control because they can contain credentials or session information. See Playwright authentication.

A simple setup can load a previously created storage state for a private visual project:

// In playwright.config.ts, add a separate project for authenticated pages:
{
  name: "chromium-authenticated",
  use: {
    ...devices["Desktop Chrome"],
    storageState: "playwright/.auth/user.json",
  },
}

Create that state using the app’s supported login flow, or use a setup project that authenticates before the visual tests. If login state expires, refresh it through the setup rather than weakening the route assertion. Prefer deterministic fixtures or a test API to create exact records; relying on production-like data that changes over time makes visual diffs difficult to interpret.

For asynchronous pages, wait for a stable, meaningful signal such as a page heading, test ID, loaded record count, or application-owned “ready” marker. Avoid treating networkidle as a universal readiness guarantee: analytics, polling, and streaming can keep requests active, while a quiet network can still precede a late UI update.

6. Choose full-page, viewport, responsive, and state coverage

Use fullPage: true when the document’s complete vertical layout is the contract you want to protect. Use the default viewport screenshot when the first screen or a fixed-height application view is the target. Full-page images can be large and may expose content that appears only after scrolling; confirm that lazy-loaded content has actually loaded before capture.

Add viewport projects for important breakpoints, not every possible width. For example, a desktop project and a mobile project catch responsive layout changes, while the route list stays the same. Also add distinct test cases for meaningful UI states such as an open navigation menu or an empty table. Name state cases explicitly so a failure tells you what was captured.

test("home mobile menu open", async ({ page }) => {
  await page.goto("/");
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
  await page.getByRole("button", { name: "Open menu" }).click();
  await expect(page.getByRole("navigation")).toBeVisible();
  await expect(page).toHaveScreenshot("home-menu-open.png", {
    fullPage: false,
    animations: "disabled",
  });
});

Browser and viewport choices multiply the number of captures. Start with the combinations that represent real user layouts and add coverage when a browser-specific difference matters.

7. Reduce noise without masking real regressions

  • Freeze data: seed records and use fixed dates, prices, and user names.
  • Control time and locale: set the same timezone, locale, color scheme, and test clock behavior on every run.
  • Wait for meaningful readiness: verify the content under test, not just that navigation began.
  • Disable or finish animations: the screenshot assertion accepts animation options and disables animations in this example.
  • Hide irrelevant volatility selectively: Playwright supports a screenshot stylesheet for volatile elements. Hide a rotating ad or irrelevant clock only when its pixels are outside the visual contract; do not hide the component being tested.
  • Use masks with care: if a changing region is intentionally nondeterministic, masking can isolate the surrounding layout, but a mask can also conceal a real defect in that region.
  • Keep the rendering environment aligned: use the same OS image, browser, installed fonts, and headless mode for baselines and checks.

For example, a screenshot-only stylesheet can hide a timestamp that is not part of the page’s visual contract:

await expect(page).toHaveScreenshot("activity.png", {
  fullPage: true,
  animations: "disabled",
  style: "[data-testid='live-timestamp'] { visibility: hidden !important; }",
});

Use a pixel threshold only after reviewing why a small difference is acceptable. Raising maxDiffPixels can reduce sensitivity, but it can also let a real regression pass. Playwright documents options such as maxDiffPixels and screenshot styling in its visual comparison guide.

For a public site, sitemap URLs and same-origin internal links can reveal routes missing from the manifest. Treat discovery as an audit input, not proof of universal coverage. Crawls need explicit rules for maximum depth, path allowlists, exclusions, duplicate query strings, redirects, and pages that require cookies or authentication.

A safe workflow is: collect discovered URLs; resolve relative links against the site origin; retain only allowed same-origin paths; remove fragments; apply the same trailing-slash and query policy as the manifest; deduplicate; then compare the discovered set with the maintained route set. Review any differences and decide whether they are supported pages, redirects, parameter variants, or intentionally excluded URLs. Avoid blindly crawling arbitrary links or submitting forms, since those actions can leave the intended read-only page set.

9. Run visual checks in CI

Run the same command locally and in CI, install the browser version associated with the lockfile, and use a stable runner image. Commit reference snapshots so CI has the expected images. Configure CI to fail when an unapproved visual diff appears, then publish the test report and screenshot artifacts so reviewers can inspect the actual, expected, and diff images.

npm ci
npx playwright install --with-deps chromium
npx playwright test tests/visual-routes.spec.ts

Parallel workers can speed up a large route set, but they also increase resource use and can stress a local app or shared test data. If pages interfere through shared state or the CI runner is resource constrained, reduce workers or isolate the fixtures. Retry settings can help diagnose transient infrastructure failures, but retries should not be used to make a genuinely unstable screenshot appear reliable.

10. Troubleshooting common failures

Symptom Likely cause Fix
Every route shows the same page or login screen Client routing fallback, missing base URL, or absent authentication Check the resolved URL and response; load the correct auth state and assert a route-specific heading or marker.
“Snapshot doesn’t exist” on first run No approved reference has been created yet Inspect the generated screenshot, then commit it as the initial baseline if it is correct.
Diffs appear on every CI run Different OS, browser, fonts, rendering mode, data, or timing Align baseline and CI environments; pin dependencies; seed data; wait for the relevant page state.
Capture times out waiting for a selector The selector is wrong, the route failed, data is missing, or the page never reaches the expected state Check the trace and actual URL, confirm test data, and use an application-owned readiness signal.
Diffs show a spinner or skeleton The screenshot is taken before content finishes loading Wait for the final content or an explicit ready marker before the visual assertion.
Images are blank or below the fold is incomplete Images are lazy-loaded or remote assets failed Make the test data and image host available; scroll or trigger the app’s lazy-loading behavior before the full-page shot.
Snapshot name or path is invalid Names collide or the path escapes the test’s snapshot directory Use stable unique names; keep custom snapshot paths within the per-test snapshot folder.
Tests pass locally but fail in CI Different fonts, browser build, OS, viewport, or machine resource pressure Run baselines in the same CI image and browser project; inspect trace and artifacts before changing thresholds.
Many tests fail after a visual change A shared component changed or references are stale Review the diffs across routes; update snapshots only after approving the intended change.

11. Local snapshots or hosted visual review?

Native Playwright keeps reference snapshots with the test suite and compares them on subsequent runs. This fits teams that want repository-managed baselines and local comparison. A hosted service can provide a different review workflow. Percy provides a Playwright integration for hosted visual review; review how its baselines are managed and whether CI waits for approval or needs a separate gate before adopting it. See the Percy Playwright integration documentation and BrowserStack’s guidance on CI approval gating.

Compare local versus hosted review, repository-managed versus service-managed baselines, browser and viewport coverage, CI failure behavior, and the effort needed to maintain route and state fixtures. Neither workflow removes the need to define what routes and states count as covered.

12. Performance, reliability, and cost

The main cost of a route-wide suite is capture count and the time to navigate, prepare state, and compare each image. A rough planning unit is routes × viewport projects × visual states, with additional runs for browsers or authenticated fixtures. Full-page screenshots and remote assets can add time and produce large artifacts. Keep the suite focused on supported routes and representative data, run it on a consistent worker image, and split or shard it when the suite becomes too slow.

For reliability, favor isolated tests, deterministic data, unique accounts or fixtures, and explicit readiness checks. A route manifest makes omissions visible in code review; a crawl comparison can flag newly linked public pages. A passing visual test says that the captured route, viewport, state, and browser matched its reference within the configured comparison options. It does not establish functional correctness or cover states the test never reached.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request can return a screenshot for a URL; it can capture full pages and offers options for viewports, device presets, caching, and batch capture. It is useful for route inventory reviews, documentation, and agent workflows; Playwright remains the right fit when you need repository-managed visual assertions against approved baselines.

For route-by-route capture, send one request per manifest URL, save each result under a stable route name, and retain your route and state coverage logic. The API captures a URL but does not discover all routes or replace a visual regression baseline and diff workflow.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(async fs => {
  await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
});

See the ScreenshotNeo API documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; 1,000 screenshots a month are free with no card, and paid plans start at $5 for 3,000. Sign up for free and capture 1,000 screenshots a month with no card.

Frequently asked questions

Does Playwright automatically find every route?

No. You provide the route list or a deliberate discovery process. The test covers only the URLs it visits.

Should I make a baseline screenshot for every possible record ID?

Usually no. Choose representative records and explicit edge cases, and keep the data stable so each baseline remains meaningful.

Should I update snapshots whenever CI reports a diff?

No. Inspect the diff first. Update the reference only after confirming that the changed appearance is expected.

Can a screenshot test replace functional tests?

No. It checks rendered pixels for a specific browser, viewport, and state. Use functional assertions for behavior and content rules that pixels alone do not verify.