Automate Documentation Screenshots

Keep product screenshots in your docs current with a manifest in the repo, element captures with PII hidden, and a CI job that opens a PR per release.

By Simon Chiu · Updated

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.

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.

Frequently asked questions

Why not turn on cache reuse to save credits on unchanged screens?
Cache reuse matches on the request options, and a docs request is the same before and after a release. With cache.available set, the API would hand back last release's image for a changed screen. Render fresh and let the pull request diff show what changed.
Should I hide or remove elements that contain personal data?
Hide keeps the element's space and blanks it, so tables and forms keep their shape. Remove collapses the element, which suits banners and toasts. The better fix is a demo account seeded with fake data, with hiding as a second line of defense.
How does the capture get past the login?
Send the demo account's session cookie in network.cookies, read from a CI secret. The cookie is set only for the demo domain. A content check for the sign-in page text makes a logged-out capture fail instead of shipping a picture of the login form.
What happens when a selector in the manifest no longer exists?
With selector_required set, the request fails with selector_not_found and costs nothing. The script collects the failure and exits 1, so the CI job fails and someone fixes the manifest.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.