Every release makes some of your documentation screenshots wrong. A button moves, a menu gets a new item, the settings page gets a new tab, and the docs keep showing the old one until a customer asks why their screen looks different. Re-taking 40 screenshots by hand before each release does not happen, so they rot. List every docs screenshot in a manifest file in the repo, capture them in CI on each release with element selectors and hidden personal data, and let a pull request show the image diffs for review.
It is one of the use cases where the target is your own product, so you control the selectors, the demo data and the login.
The request gallery
Each line of the manifest becomes one of these requests against your demo environment (demo.example.com stands in for it).
| Screenshot | Key options | Why |
|---|---|---|
| Full screen of a page | "viewport": {"width": 1280, "height": 800, "scale": 2}, "wait": {"for_selector": "[data-testid=\"usage-chart\"]"} |
Scale 2 keeps text sharp on high-density screens; the wait stops a half-loaded chart |
| One panel or card | "capture": {"mode": "element", "selector": "[data-docs=\"api-keys\"]", "selector_required": true} |
Crops to the part the article talks about, and fails loudly if the panel is renamed |
| Dark-mode variant | "browser": {"dark_mode": true} |
For docs sites that switch images with the reader's theme |
| Personal data blanked | "page": {"hide": ["[data-pii]"]} |
Names and emails disappear, layout stays |
| Toasts and banners gone | "page": {"remove": [".toast", ".trial-banner"]} |
They collapse instead of leaving a gap |
| Stable pixels | "browser": {"reduced_motion": true}, "page": {"styles": ["* { caret-color: transparent !important; }"]} |
Fewer pure-noise differences between releases |
Add data-docs attributes to the elements you capture. Class names change with every redesign; an attribute that exists only for the docs changes when someone decides it should. The element screenshots guide explains selector choice in more depth.
How it works
flowchart LR
R[Release published] --> C[CI job]
M[screenshots.yml] --> C
C -->|one POST per shot<br/>session cookie, hide, element| API[RenderScreenshot]
API -->|PNG| C
C -->|any file changed?| G{git status}
G -->|yes| PR[Pull request with image diffs]
G -->|no| E[Exit, nothing to review]
PR --> D[Docs site after merge]
The pull request is the review step. GitHub shows changed images side by side, so the reviewer sees exactly which screens moved in this release, and an unexpected change (a debug banner, a broken layout) gets caught before it reaches the docs.
The manifest
Keep it next to the docs, reviewed like code:
# screenshots.yml: every product screenshot in the docs, in one reviewed file. base_url: https://demo.example.com defaults: viewport: { width: 1280, height: 800, scale: 2 } hide: ['[data-pii]', '.intercom-launcher'] shots: - id: dashboard-overview path: /dashboard wait_for: '[data-testid="usage-chart"]' - id: api-keys-table path: /settings/api-keys selector: '[data-docs="api-keys"]' - id: billing-plan-card path: /settings/billing selector: '[data-docs="plan-card"]' dark: true
The capture script
// docs-screenshots.mjs: re-capture every screenshot listed in screenshots.yml. // Usage: RS_API_KEY=rs_live_... DOCS_SESSION=... node docs-screenshots.mjs screenshots.yml docs/images import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { parse } from 'yaml'; const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const [manifestPath = 'screenshots.yml', outDir = 'docs/images'] = process.argv.slice(2); const manifest = parse(await readFile(manifestPath, 'utf8')); const { hostname } = new URL(manifest.base_url); function requestFor(shot) { const d = manifest.defaults ?? {}; return { url: new URL(shot.path, manifest.base_url).href, viewport: { ...d.viewport, ...shot.viewport }, capture: shot.selector ? { mode: 'element', selector: shot.selector, selector_required: true } : { mode: 'viewport' }, wait: { until: 'networkidle', for_selector: shot.wait_for ?? shot.selector, timeout: 30 }, page: { hide: [...(d.hide ?? []), ...(shot.hide ?? [])], // keeps layout, blanks the content remove: shot.remove ?? [], // collapses banners and toasts styles: ['*, *::before, *::after { caret-color: transparent !important; }'], }, browser: { reduced_motion: true, dark_mode: Boolean(shot.dark) }, network: { cookies: [{ name: 'session', value: process.env.DOCS_SESSION, domain: hostname, secure: true, http_only: true }], }, options: { fail_if_contains: ['Sign in to continue'] }, // a logged-out page is a failed shot output: { format: 'png' }, cache: { enabled: true, available: false }, // never reuse: the URL is the same, the release is not }; } await mkdir(outDir, { recursive: true }); const failures = []; for (const shot of manifest.shots) { const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestFor(shot)), signal: AbortSignal.timeout(90_000), }); if (!res.ok) { const body = await res.json().catch(() => ({})); failures.push(`${shot.id}: HTTP ${res.status} ${body.error?.code ?? ''} ${body.error?.message ?? ''}`); continue; } await writeFile(`${outDir}/${shot.id}.png`, Buffer.from(await res.arrayBuffer())); console.log(`ok ${shot.id}`); } if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; // fail the job so a broken selector never ships a stale image silently }
The cache line is the one most people get wrong. Cache reuse matches on the request options, and this request is identical before and after a release. Turn on cache.available and the API returns the previous release's render for a screen that changed. Docs screenshots always render fresh.
The login uses a cookie from a seeded demo account, stored as a CI secret. The cookie's domain limits it to the demo host, and the fail_if_contains check turns an expired session into a failed shot instead of 40 pictures of the sign-in form. The authenticated pages guide covers other login methods.
Tested on 2026-10-18 with Node.js 22.18.0 and yaml 2.9.1 against a local stub of the API: three shots were written, and a shot whose page failed made the script print the error code and exit 1.
The CI job
name: Docs screenshots on: release: types: [published] workflow_dispatch: permissions: contents: write pull-requests: write jobs: recapture: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - name: Re-capture screenshots from the manifest env: RS_API_KEY: ${{ secrets.RS_API_KEY }} DOCS_SESSION: ${{ secrets.DOCS_SESSION }} run: node scripts/docs-screenshots.mjs screenshots.yml docs/images - name: Open a pull request if any image changed env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.release.tag_name || github.run_id }} run: | set -euo pipefail if [ -z "$(git status --porcelain docs/images)" ]; then echo "No screenshot changed." exit 0 fi branch="docs-screenshots/${TAG}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$branch" git add docs/images git commit -m "Update docs screenshots for ${TAG}" git push origin "$branch" gh pr create --title "Docs screenshots for ${TAG}" \ --body "Re-captured from screenshots.yml. Review the image diffs before merging."
It runs when a release is published, and on demand. In the repository settings, allow GitHub Actions to create pull requests, or the last command fails. The workflow passed actionlint 1.7.7; it has not run on a real release here. If your CI image cannot run Node, the CLI in CI guide shows the same captures with the rs binary.
What it costs
Each shot is one render, and a failed shot is free. A manifest of 40 shots with 10 dark-mode variants is 50 renders per release. At four releases a month that is 200 renders, which fits the Starter plan (2,500 credits for $10 a month). A small product with a dozen shots and one release a month fits in the free plan's 150 monthly credits. See pricing.
Next steps
To put screenshots of other people's sites into articles, see content enrichment. Get a free API key, add data-docs to three screens, and run the script against your staging site.