Full-page screenshots of every route in your web app — at desktop and mobile — with a single command. Generates a browsable HTML report.
Use this to:
- Review your UI before a demo or deploy
- Catch visual regressions after big changes
- Share a visual overview of your app with collaborators or stakeholders
- Spot mobile layout issues without manually resizing your browser
Run npm run screenshot → open screenshots/report.html → see this:
| Page | Desktop 1280px | Mobile 390px |
|---|---|---|
| Homepage | (screenshot) | (screenshot) |
| Login | (screenshot) | (screenshot) |
| Projects list | (screenshot) | (screenshot) |
| … | … | … |
Each screenshot is clickable to open full-size. Auth-required pages are captured after a configurable login step.
Requirements: Node.js 18+
# 1. Clone
git clone https://github.com/YOUR_USERNAME/playwright-ui-audit.git
cd playwright-ui-audit
# 2. Install dependencies
npm install
# 3. Install the Chromium browser Playwright uses
npm run setup# Your dev server must be running first (e.g. npm run dev in your app)
npm run screenshot # localhost:3000, desktop + mobile
npm run screenshot:mobile # mobile only
npm run screenshot:desktop # desktop only
npm run screenshot -- --url https://staging.myapp.com # against any URL
npm run screenshot -- --url https://myapp.com --only mobile # deployed + mobile onlyThen open the report:
open screenshots/report.html # macOS
xdg-open screenshots/report.html # Linux
start screenshots/report.html # WindowsEverything you need to change is in config.ts. The engine (screenshot.ts) never needs editing.
export const BASE_URL = "http://localhost:3000"; // change thisOverride at runtime without editing the file:
npm run screenshot -- --url https://staging.myapp.comexport const ROUTES: Route[] = [
{
path: "/",
label: "Homepage",
auth: "public", // no login needed
waitFor: "main", // CSS selector — wait for this before screenshotting
},
{
path: "/dashboard",
label: "Dashboard",
auth: "user", // login() will be called first
waitFor: "main",
},
// add as many as you need
];auth values:
"public"— screenshot without logging in"user"— calls yourlogin()function first (once, then reuses the session)
waitFor:
A CSS selector the tool waits for before taking the screenshot. Prevents capturing loading spinners. Use "main" as a safe default for most apps.
In config.ts, fill in the login() function. Three options are pre-written — uncomment the one that fits:
Option A — Dev bypass button (Next.js + Supabase pattern)
export async function login(page: Page, baseUrl: string): Promise<boolean> {
const res = await page.request.post(`${baseUrl}/api/dev-login`);
if (!res.ok()) return false;
await page.goto(`${baseUrl}/login`, { waitUntil: "networkidle" });
const btn = page.locator("button", { hasText: /skip login/i }).first();
if (!(await btn.isVisible({ timeout: 3_000 }).catch(() => false))) return false;
await btn.click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 8_000 }).catch(() => {});
return !page.url().includes("/login");
}Option B — Email + password
export async function login(page: Page, baseUrl: string): Promise<boolean> {
await page.goto(`${baseUrl}/login`, { waitUntil: "networkidle" });
await page.fill("input[type='email']", "test@example.com");
await page.fill("input[type='password']", "your-test-password");
await page.click("button[type='submit']");
await page.waitForNavigation({ waitUntil: "networkidle", timeout: 8_000 });
return !page.url().includes("/login");
}Option C — No auth
export async function login(): Promise<boolean> {
return false; // mark all routes as auth: "public"
}export const VIEWPORTS = {
desktop: { width: 1280, height: 900, label: "Desktop 1280px" },
mobile: { width: 390, height: 844, label: "Mobile 390px" },
// add tablet: { width: 768, height: 1024, label: "Tablet 768px" }
};screenshots/
desktop/
homepage.png
login.png
projects-list.png
…
mobile/
homepage.png
login.png
…
report.html ← open this
screenshots/ is gitignored — it's output, not source.
If your app has routes with database-driven IDs, fetch the ID before adding the route. Example for a Supabase app:
// At the bottom of config.ts, before exporting ROUTES:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!);
const { data } = await supabase.from("products").select("id").limit(1).single();
const productId = data?.id ?? "fallback-id";
export const ROUTES: Route[] = [
// ...static routes...
{
path: `/products/${productId}`,
label: "Product detail",
auth: "public",
waitFor: "main",
},
];Make UI changes in your app
↓
npm run screenshot
↓
open screenshots/report.html
↓
Spot issues → fix in your code
↓
Repeat
For teams: run before every PR merge or before every deploy to staging. Paste the report (or specific screenshots) into your PR description.
| Tool | Best for |
|---|---|
| This tool | Fast visual review, pre-deploy sanity check, sharing with non-engineers |
| Playwright Test | Functional correctness — "does the button work?" |
| Cypress | E2E test suites with assertions |
| Stagehand | AI-driven exploration of unknown flows |
This tool is intentionally simple — it doesn't assert, it doesn't click around. It just shows you what every page looks like. Fast to set up, zero maintenance.
- Playwright — headless browser automation
- tsx — run TypeScript directly, no build step
- Chromium (installed via
npm run setup)
PRs welcome. Keep it simple — the goal is a tool that takes 5 minutes to set up and never needs updating.
Ideas welcome:
--diffflag to compare against a previous run- Slack/Discord webhook to post report after CI runs
- Lighthouse score per page alongside the screenshot
Made while building CoBuilt — a platform where builders form teams and share revenue.