Skip to main content
ASoc
Tutorial

Automated Product Screenshots with Playwright, 111 Templates Deep

The capture is four lines; deciding what is in frame is the work. Nav-driven page discovery, the is-this-the-product check, and the proxy bug that blanks every Chromium request.

The ASoc Team12 min read

Screenshotting one site with Playwright is four lines. Screenshotting 111 of them, repeatably, without a human choosing what is in frame, is a different problem: you have to decide which pages are worth showing, prove what loaded is actually the product, and get a clean frame past cookie bars, promo modals and demo login screens. The capture is the easy part.

This storefront runs two screenshot pipelines over its catalog. One clones each project and boots it; the other shoots the deployed preview and finishes a product in about twenty seconds. This is what we learned building both, including a network failure that makes Chromium unusable in some CI sandboxes.

Two pipelines, because the images have two jobs

Splitting them was the decision that made the rest tractable. The images look similar and are not interchangeable:

Cover shotsGallery slides
JobSEO surface — card art, og:image, Product schemaThe detail page's carousel
ContentOne view, cropped to 1:1 / 4:3 / 16:9~4 distinct pages of the product
SourceThe project, cloned and booted locallyThe live deployed preview
OrderingFixed — index 0 is the cover, foreverHome first, then ranked inner pages
CostMinutes per product~20 seconds per product

Keeping them separate means the expensive, fragile pipeline runs rarely and the cheap one runs whenever a template changes. Merging them would have forced every gallery refresh through a clone.

The expensive pipeline, and why it is the fallback

The cover pipeline clones the edition repository, installs dependencies, boots the dev server on an assigned port, shoots the ratio crops, and deletes the workspace.

Three things dominate its cost, and they are all disk:

The clone is the expensive part on disk (node_modules runs 300-900MB),
so a product's workspace is removed as soon as its shots land — peak
usage is CONCURRENCY workspaces, not 100+.

At three workers that is up to ~2.7 GB live rather than potentially 100 GB, which is the difference between a job that finishes and one that fills the volume at product forty. If you build a fan-out capture job, bound the concurrency and delete each workspace on completion before you tune anything else.

The second cost is egress. Templates pull their photography from an image CDN, and a dev server without outbound access renders the hero as a broken box — a screenshot that looks like a successful run and is worthless. Verify network access before the batch, not after.

Shoot the deployment instead

Every edition already has a live preview URL. Pointing the camera at that removes the clone, the install, the port allocation and the dev-server boot — minutes down to seconds — and it has a correctness advantage that matters more: you are photographing exactly what a buyer will see when they click "Live preview", not a dev build with an overlay in the corner.

The trade is that you cannot read the project's routes off the filesystem. You have to discover them.

Discovering what to shoot: the nav is the product tour

A template's own navigation is a ranked list of what its author thinks matters. Harvest the internal links, drop the ones nobody wants to see, and rank the rest.

The deny list is the part that took two attempts. Matching only the first path segment misses the common case:

const SKIP = [
  "privacy", "terms", "legal", "license", "cookie", "refund",
  "404", "not-found", "auth", "login", "signin", "signup",
  "forgot", "reset", "logout", "cart", "checkout", "wishlist",
  "account", "search", "thank-you", "success", "error", "empty",
];

// Matched on ANY path segment, not just the first: admin templates nest
// their auth screens under /auth/..., which a root-anchored pattern
// would happily shoot.
const DENY = new RegExp(`(^|/)(${SKIP.join("|")})(/|$)`, "i");

Ranking is a small ordered list of patterns — catalogues and dashboards first, then detail pages, then features, pricing, about, blog, and contact last — with a depth penalty so a top-level section beats a single record:

function score(path) {
  const i = PRIORITY.findIndex((re) => re.test(path));
  const base = i === -1 ? 50 : i;
  const depth = path.split("/").filter(Boolean).length;
  return base * 10 + (depth > 1 ? 5 : 0);
}

Then one refinement that changes the output more than the ranking does: take the first page of each section before taking a second page of any section. Three blog posts is not a product tour. Runners-up stay on the end of the candidate list rather than being dropped, so a route that 404s costs a candidate instead of a slide.

One viewport, always

Every slide is captured at exactly 1600×900. Not because it is a good size, but because the carousel reserves one 16:9 box: a stray aspect ratio makes the slides jump as they advance, and a viewer reads that as a broken component.

Fix the viewport, force colorScheme: "light" (a template that respects prefers-color-scheme will otherwise hand you a dark shot into a light card), and let the layout be whatever it is.

Getting a clean frame

Four things stand between the camera and the product, and each needs its own handling.

Persistent chrome. Cookie bars, dev overlays, error toasts. Inject CSS that hides them by selector before the shot — faster and more reliable than clicking.

Timed modals. Newsletter and promo popups fire on a timer or on scroll depth, which means they tend to land exactly when the screenshot does, dimming the whole page. Try Escape, then the dialog's own labelled close button, then hide what remains.

Demo login screens. Admin templates deploy behind their own sign-in with the demo account prefilled and printed on the form. Submitting it is what the demo is for — otherwise every admin product's carousel is four pictures of a login box. Note the guard, which is the part worth copying:

const pw = await page.$('input[type="password"]');
if (!pw) return false;
const filled = await pw.evaluate((el) => el.value.length > 0);
if (!filled) return false;   // only ever submit credentials the page itself filled in

It never types a credential and never guesses one. If the template did not prefill the form, the script gives up and moves on.

Pages that are not the product. This is the check most capture scripts lack, and the one that saves you from shipping garbage. A stubbed route ("Pricing — this page is coming soon") and Chromium's own network-error page both photograph perfectly:

// Phrases that mean the viewport is showing anything except the product:
// a route the template stubbed out, or the browser's own failure page.
const NOT_THE_PRODUCT =
  /coming soon|under construction|couldn'?t load|failed to load|page not found|err_|internal server error|this page is/i;

async function pageIsUsable(page) {
  const text = await page.evaluate(() => document.body?.innerText?.trim() ?? "");
  // A real page of a marketing template carries far more copy than this;
  // a stub carries a heading and one sentence.
  if (text.length < 150) return false;
  return !NOT_THE_PRODUCT.test(text.slice(0, 300));
}

A word-count floor plus a phrase list catches nearly all of it. Without it, a batch reports 111 successes and produces a dozen slides of empty states.

The network trap: Chromium and an egress proxy

This is the finding that cost the most time, and it is not in Playwright's documentation because it is not Playwright's fault.

In a sandbox where outbound traffic must pass through an HTTP proxy, Chromium's own network stack can fail every request with ERR_CONNECTION_RESET while Node's fetch in the same process works perfectly. Nothing loads; the browser launches fine; there is no useful error.

The fix is to stop letting Chromium do the networking. Intercept every request and re-issue it through Node:

await ctx.route("**/*", async (route) => {
  const req = route.request();
  try {
    const r = await fetch(req.url(), {
      method: req.method(),
      headers: req.headers(),
      body: ["GET", "HEAD"].includes(req.method()) ? undefined : req.postDataBuffer(),
      redirect: "follow",
    });
    const body = Buffer.from(await r.arrayBuffer());
    const headers = {};
    r.headers.forEach((v, k) => {
      // Re-encoding is already undone by fetch; forwarding these makes
      // Chromium reject the body as malformed.
      if (!["content-encoding", "content-length"].includes(k.toLowerCase()))
        headers[k] = v;
    });
    await route.fulfill({ status: r.status, headers, body });
  } catch {
    await route.abort();
  }
});

The header filter is not optional. fetch has already decompressed the body, so forwarding the original content-encoding and content-length makes Chromium reject a response that is perfectly fine — which presents as a blank page rather than an error, and sends you looking in the wrong place.

When a template has no inner pages

Single-page templates and fixed-viewport dashboards still need a full carousel. Two fallbacks, in order:

  1. Scroll the home page and shoot section boundaries — still distinct views. Guarded by a height check: on a viewport-height dashboard, every "fill" would be a byte-identical copy of slide one, so the script warns instead of producing duplicates.
  2. Click the view switcher, for an app whose nav rail swaps the view in place with no URL to navigate to. It runs last, deliberately, because on a normal landing page those controls are dropdown toggles and an open menu over the hero is a worse slide than any real section. It skips anything with aria-haspopup or aria-expanded, and discards a click that did not change the page text — a control that changed nothing on screen is not a view switcher.

Both fallbacks exist because the alternative is a short carousel, and a carousel with two slides where its neighbours have four looks like a defect in the product rather than in the pipeline.

Mistakes and how they show up

MistakeSymptomFix
Unbounded fan-outDisk full mid-batch, half the run lostBound concurrency; delete each workspace as it finishes
No "is this the product?" checkBatch reports success; slides show empty statesText-length floor plus a phrase blocklist before saving
Variable viewport per shotCarousel jumps as slides advanceOne fixed viewport for every slide
Trusting prefers-color-schemeDark screenshots inside light cardsForce colorScheme explicitly
Root-anchored deny patternsLogin pages shot from nested auth routesMatch on any path segment
Shooting three pages of one section"Tour" that is three blog postsFirst page of each section before any second
Assuming Chromium honours the proxyEvery request resets; no useful errorIntercept and re-issue through Node's fetch
Forwarding content-encoding when you doBlank pages, no errorStrip encoding and length headers on fulfil
Playwright as a project dependencyEvery CI install pays for a yearly scriptInstall ad hoc, unpinned to the app
One pipeline for SEO art and gallery artEvery carousel refresh needs a full cloneSeparate pipelines; separate cadence

Frequently asked questions

Should Playwright be a dependency of the app? Not for a script that runs a handful of times a year — it would cost every CI install for the whole team. Install it ad hoc in the job that needs it. It belongs in devDependencies when it is your test runner, which is a different question.

Is networkidle the right wait? It is a reasonable default for a static marketing page and unreliable for anything with polling, analytics beacons or streaming. Prefer waiting on a specific element when you know the page; use networkidle plus a short settle delay for the general case, which is what a fan-out job over unknown pages actually is.

JPEG or PNG for product screenshots? JPEG at high quality for photographic, gradient-heavy marketing pages; the file-size difference is large and the artefacts are invisible at card size. Convert to WebP in a separate build step rather than capturing it — that keeps the capture output canonical and the delivery format a choice you can change later.

How do you know a re-capture did not make things worse? Compare before shipping. A slide that suddenly contains far less text than its predecessor is nearly always a stub, an error page, or a modal that beat the dismissal logic — the same pageIsUsable heuristic makes a decent regression check when run against the previous output.

Can this run on a schedule? Against deployed previews, yes — that is the whole reason the cheap pipeline exists. The clone-and-boot pipeline is better run deliberately, because its failure modes (network, install, port, boot timeout) are the kind you want a person to read.

Templates whose galleries came out of this pipeline

Every carousel on this site was produced by the process above — home page first, then the highest-ranked inner pages discovered from the template's own navigation. The templates below are the multi-page kind that make it worth doing: listing pages, detail pages, editorial feeds and sector pages, all of which the ranker finds without being told they exist.

Keep reading

Tutorial10 min read

A Podcast Landing Page Is a Registry, a Feed, and One CSP Line

Episodes belong in a typed registry, the feed is the actual product, and the player embed renders blank unless your Content-Security-Policy names its host.

Read more
Tutorial13 min read

Programmatic SEO in Next.js: Derive the Pages, Don't List Them

A hand-maintained slug list left 24 of our products on no category page at all, and nothing failed. Here is the derived hub-and-spoke build that fixed it.

Read more