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.
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 shots | Gallery slides | |
|---|---|---|
| Job | SEO surface — card art, og:image, Product schema | The detail page's carousel |
| Content | One view, cropped to 1:1 / 4:3 / 16:9 | ~4 distinct pages of the product |
| Source | The project, cloned and booted locally | The live deployed preview |
| Ordering | Fixed — index 0 is the cover, forever | Home first, then ranked inner pages |
| Cost | Minutes 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:
- 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.
- 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-haspopuporaria-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
| Mistake | Symptom | Fix |
|---|---|---|
| Unbounded fan-out | Disk full mid-batch, half the run lost | Bound concurrency; delete each workspace as it finishes |
| No "is this the product?" check | Batch reports success; slides show empty states | Text-length floor plus a phrase blocklist before saving |
| Variable viewport per shot | Carousel jumps as slides advance | One fixed viewport for every slide |
Trusting prefers-color-scheme | Dark screenshots inside light cards | Force colorScheme explicitly |
| Root-anchored deny patterns | Login pages shot from nested auth routes | Match on any path segment |
| Shooting three pages of one section | "Tour" that is three blog posts | First page of each section before any second |
| Assuming Chromium honours the proxy | Every request resets; no useful error | Intercept and re-issue through Node's fetch |
Forwarding content-encoding when you do | Blank pages, no error | Strip encoding and length headers on fulfil |
| Playwright as a project dependency | Every CI install pays for a yearly script | Install ad hoc, unpinned to the app |
| One pipeline for SEO art and gallery art | Every carousel refresh needs a full clone | Separate 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.
