Migrate from BackstopJS
A complete BackstopJS migration guide. Frontguard is a modern BackstopJS alternative and BackstopJS replacement with AI analysis, Playwright rendering, and zero-config baselines.
Migrate from BackstopJS
BackstopJS was the de-facto open-source visual regression tool for years (~73K downloads/week), but it is effectively unmaintained and still rides on PhantomJS-era assumptions, hand-written backstop.json scenario files, and a "red pixels differ" reporting model.
Frontguard is a modern, CLI-first BackstopJS alternative: Playwright-native rendering, git-orphan-branch baselines (no committed PNGs to wrestle with), anti-flake multi-render consensus, and AI vision analysis that explains why a diff happened instead of just highlighting pixels. It's MIT-licensed and self-hostable.
This guide maps every common backstop.json concept to its Frontguard equivalent so you can migrate scenario-by-scenario without losing coverage.
Side-by-Side Config Comparison
BackstopJS uses a JSON file (backstop.json). Frontguard uses a typed frontguard.config.ts so your config is validated by TypeScript.
{
"id": "my_project",
"viewports": [
{ "label": "phone", "width": 375, "height": 667 },
{ "label": "tablet", "width": 768, "height": 1024 },
{ "label": "desktop", "width": 1440, "height": 900 }
],
"scenarios": [
{
"label": "Homepage",
"url": "https://localhost:3000/",
"readySelector": ".hero-loaded",
"misMatchThreshold": 0.1,
"selectors": ["document"],
"hideSelectors": [".cookie-banner"]
},
{
"label": "Pricing",
"url": "https://localhost:3000/pricing",
"misMatchThreshold": 0.05
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test"
},
"engine": "playwright",
"report": ["browser"]
}export default {
baseUrl: 'http://localhost:3000',
viewports: [375, 768, 1440],
browsers: ['chromium'],
threshold: 0.1,
smartRender: true,
routes: [
{ path: '/', threshold: 0.1, ignore: [{ selector: '.cookie-banner' }] },
{ path: '/pricing', threshold: 0.05 },
],
ai: {
provider: 'openai',
model: 'gpt-4o',
},
};Notice what disappears: there are no paths, no bitmaps_reference/bitmaps_test directories, and no id. Frontguard stores baselines on a git orphan branch automatically — you never commit binary PNGs into your working tree.
Scenario Mapping: Scenarios → Routes
A BackstopJS scenario is a URL plus rendering options. In Frontguard, that becomes a route — either a simple string or an object when you need per-route overrides.
| BackstopJS scenario | Frontguard route |
|---|---|
{ "label": "Home", "url": ".../" } | '/' (string form) |
{ "url": ".../pricing", "misMatchThreshold": 0.05 } | { path: '/pricing', threshold: 0.05 } |
{ "url": ".../app", "viewports": [...] } | { path: '/app', viewport: 1440 } |
hideSelectors / removeSelectors | ignore: [{ selector }] (per route or global) |
export default {
baseUrl: 'http://localhost:3000',
routes: [
'/', // simple string
'/pricing',
{ path: '/checkout', threshold: 0.02 }, // tighter threshold
{
path: '/dashboard',
viewport: 1440, // desktop only
ignore: [{ selector: '.live-clock', description: 'Updates every second' }],
},
],
};The url in a BackstopJS scenario is an absolute URL. In Frontguard, set baseUrl once and use relative paths — cleaner config and easy to repoint at staging/prod.
Feature Comparison
| Concept | BackstopJS | Frontguard | Notes |
|---|---|---|---|
| Config format | backstop.json | frontguard.config.ts (typed) | ✅ Maps |
| Scenario / page | scenarios[] | routes[] | ✅ Maps |
misMatchThreshold | per-scenario | threshold (per-route or global) | ✅ Direct mapping |
viewports | [{label,width,height}] | viewports: number[] | ✅ Maps (widths only) |
readySelector | wait for selector | smartRender + waitForSelector | ✅ Maps + improved |
hideSelectors / removeSelectors | hide elements | ignore: [{ selector }] | ✅ Maps |
| Reference vs test bitmaps | bitmaps_reference / bitmaps_test dirs | git-orphan-branch baselines | ✨ Different (no committed PNGs) |
| Rendering engine | Puppeteer/Playwright | Playwright-native | ✅ Maps |
| Anti-flake | manual delay | multi-render consensus | ✨ New |
| Why a diff happened | ❌ pixels only | AI vision classification | ✨ New |
| Suggested fixes | ❌ | AI suggested CSS fix | ✨ New |
| Approve baselines | backstop approve | frontguard update-baselines | ✅ Maps |
| License | MIT | MIT | ✅ Same |
readySelector → smartRender + waitForSelector
BackstopJS's readySelector blocks the screenshot until a selector appears. Frontguard handles most of this automatically with smartRender (waits for network idle, fonts, images, and layout stabilization), and lets you add an explicit selector wait when you need it.
{
"label": "Dashboard",
"url": "https://localhost:3000/dashboard",
"readySelector": ".chart-rendered",
"delay": 500
}export default {
baseUrl: 'http://localhost:3000',
smartRender: true, // auto-waits for network idle, fonts, images, layout
routes: [
{
path: '/dashboard',
// explicit selector wait when smartRender isn't enough
waitForSelector: '.chart-rendered',
},
],
};In practice, smartRender: true removes the need for most readySelector + delay combos. Add waitForSelector only for app-specific "fully painted" markers (charts, lazy data, animations finishing).
misMatchThreshold → threshold (direct mapping)
This is a 1:1 mapping. BackstopJS's misMatchThreshold and Frontguard's threshold are both a fraction of changed pixels (0.0–1.0). A BackstopJS misMatchThreshold: 0.1 becomes threshold: 0.1.
export default {
threshold: 0.1, // global default
routes: [
{ path: '/marketing', threshold: 0.2 }, // looser
{ path: '/checkout', threshold: 0.01 }, // strict
],
};viewports → viewports (direct mapping)
BackstopJS viewports are objects with label, width, and height. Frontguard viewports are just widths (height is determined by full-page capture). Drop the labels and heights — keep the widths.
BackstopJS Frontguard
[ viewports: [375, 768, 1440]
{ label:"phone", width:375, height:667 },
{ label:"tablet", width:768, height:1024 },
{ label:"desktop", width:1440, height:900 }
]reference / test → Frontguard baselines
This is the biggest mental-model shift. BackstopJS keeps two directories of PNGs (bitmaps_reference and bitmaps_test) and you typically commit references into git. Frontguard stores baselines on a dedicated git orphan branch — no binary blobs in your working tree, no merge conflicts on PNGs.
| BackstopJS | Frontguard |
|---|---|
backstop reference | frontguard baseline (capture baselines) |
backstop test | frontguard run (compare against baselines) |
backstop approve | frontguard update-baselines (promote current → baseline) |
bitmaps_reference/ (committed PNGs) | git orphan branch (auto-managed) |
bitmaps_test/ (gitignored output) | ./frontguard-report (gitignored) |
After migrating, delete backstop_data/ from your repo. Frontguard does not read it, and committed reference PNGs are no longer needed.
Step-by-Step Migration Checklist
- Install Frontguard and remove BackstopJS.
npm uninstall backstopjs npm install -D @frontguard/cli - Generate a starter config.
npx -p @frontguard/cli frontguard init - Port
viewports— keep widths, drop labels/heights. - Port each scenario to a route —
url→ relativepathunder a singlebaseUrl. - Map thresholds —
misMatchThreshold→threshold(1:1). - Map ready states — enable
smartRender: true; addwaitForSelectoronly where needed. - Map hidden elements —
hideSelectors→ignore: [{ selector }]. - Capture fresh baselines.
npx -p @frontguard/cli frontguard baseline - Run a comparison to confirm parity.
npx -p @frontguard/cli frontguard run - (Optional) Enable AI analysis — set
FRONTGUARD_OPENAI_KEYand add anaiblock. - Delete
backstop_data/andbackstop.jsonfrom the repo. - Update CI — replace
backstop testwithfrontguard run.
Common Gotchas
Don't reuse BackstopJS reference PNGs. Frontguard renders with its own Playwright pipeline and anti-flake consensus; baselines must be captured fresh with frontguard baseline. Old BackstopJS bitmaps will not match.
- Viewport heights are gone. Frontguard captures full-page by default. If you relied on a fixed BackstopJS viewport height to crop, use
viewportHeight/maxHeightin config instead. selectors(element-level shots) aren't 1:1. BackstopJS could screenshot a single selector. Frontguard captures pages/routes; mask noise withignoreinstead of cropping to a selector.- Absolute URLs → relative paths. Move the host into
baseUrl. Hardcodedhttps://localhost:3000/...in every scenario becomes'/...'. onReadyScript/onBeforeScript(Puppet scripts). For complex interactions (login, hovers), use the Playwright plugin orauth.storageStateinstead of Backstop engine scripts.- First run after migration will "fail" everything. That's expected — you have no baselines yet. Run
frontguard baselinefirst, thenfrontguard run.
Config Converter Snippet
Drop this Node script in your repo root to auto-translate an existing backstop.json into a starter frontguard.config.ts. It handles viewports, scenarios → routes, misMatchThreshold → threshold, and hideSelectors → ignore.
import { readFileSync, writeFileSync } from 'node:fs';
const backstop = JSON.parse(readFileSync('backstop.json', 'utf8'));
// Viewports: keep widths only
const viewports = [...new Set((backstop.viewports || []).map((v) => v.width))];
// Derive baseUrl from the first scenario URL
const firstUrl = backstop.scenarios?.[0]?.url || 'http://localhost:3000';
const baseUrl = new URL(firstUrl).origin;
// Scenarios -> routes
const routes = (backstop.scenarios || []).map((s) => {
const path = new URL(s.url, baseUrl).pathname;
const route = { path };
if (typeof s.misMatchThreshold === 'number') {
route.threshold = s.misMatchThreshold;
}
const hidden = [...(s.hideSelectors || []), ...(s.removeSelectors || [])];
if (hidden.length) {
route.ignore = hidden.map((selector) => ({ selector }));
}
// readySelector -> waitForSelector
if (s.readySelector) route.waitForSelector = s.readySelector;
// Collapse to a plain string when no overrides are needed
return Object.keys(route).length === 1 ? path : route;
});
const config = `export default {
baseUrl: ${JSON.stringify(baseUrl)},
viewports: ${JSON.stringify(viewports.length ? viewports : [375, 768, 1440])},
browsers: ['chromium'],
threshold: 0.1,
smartRender: true,
routes: ${JSON.stringify(routes, null, 2)},
// ai: { provider: 'openai', model: 'gpt-4o' },
};
`;
writeFileSync('frontguard.config.ts', config);
console.log('Wrote frontguard.config.ts with', routes.length, 'routes');Run it:
node convert-backstop.mjs
# then capture fresh baselines
npx -p @frontguard/cli frontguard baselineThe converter produces a starter config — review it, enable AI, and tune per-route thresholds before capturing baselines.
Next Steps
- AI Analysis — understand why diffs happen
- Configuration reference — every
frontguard.config.tsoption - GitHub Actions — wire
frontguard runinto CI
GitHub Actions
Run Frontguard on every pull request with GitHub Actions. Auto-post visual regression results as PR comments.
Migrate from Lost Pixel
A Lost Pixel migration guide and Lost Pixel alternative. Lost Pixel is archived — move to Frontguard for a free-forever CLI, Playwright rendering, and AI visual classification.