· Tutorials · By Simon Chiu

Screenshots of Preview Deployments in Every PR

Wait for the preview deployment to serve the PR's commit, render key pages next to production, and post a before/after table in the PR. Tested scripts.

To put screenshots of a preview deployment into a pull request, you need three things in order: the preview's URL, proof that the preview serves the pull request's latest commit, and a comment that shows each page on production next to the same page on the preview. This tutorial builds all three with two small Node scripts and one GitHub Actions workflow. The wait is the part most setups get wrong, so it gets the most space.

The shortest version of the idea, as a fragment of the full scripts below:

// 1. Poll until the preview's /version.txt equals the PR head SHA (free, plain fetch).
// 2. For each page: POST /v1/screenshot for production and for the preview, Accept: application/json.
// 3. Put both image.url links in one PR comment, found again by a hidden marker on the next push.

If your host is Vercel and you only need the preview images, the reusable workflow in our GitHub Actions integration is shorter: it starts on deployment_status, after Vercel reports the deployment. This post is for the other cases: hosts that do not report deployments to GitHub, previews at a predictable URL, and reviewers who want to see production next to the change.

Tested on 2026-10-18 with Node 22.18.0 on macOS. Both scripts have no dependencies. The workflow file passed actionlint 1.7.7. We ran wait-for-preview.mjs against a local server whose /version.txt answered 404, then an old SHA, then the right SHA; it waited 5 s and 10 s and exited 0 on the third poll. It also exited 1 after its timeout with the server down (ECONNREFUSED) and with a SHA that never arrived. We ran pr-shots.mjs twice against a local stub of our API and a mock of the GitHub comments API: the first run created the comment, the second edited it. We did not run the workflow on GitHub or against the live API, so this post has no screenshots of the result.

Why a pull_request trigger cannot just take the screenshot

A pull_request workflow starts when you push. The host starts its build at the same moment. If the workflow captures the preview URL at once, one of three things happens:

What the preview URL returns Why What you would post
404 or a DNS error The first build of this PR has not finished An error page
200 with the previous commit The alias still points to the last successful build A screenshot that does not show the change under review
401, or a login page The host protects previews A picture of a login form

The second row is the dangerous one, because it looks correct. A reviewer approves a screenshot of the old code. So the workflow must not ask "is the preview up?" It must ask "does the preview serve this commit?"

Step 1: find the preview URL for your host

Host Preview URL How to get it in the workflow Reports deployments to GitHub
Netlify deploy previews https://deploy-preview-<PR number>--<site name>.netlify.app Build it from the PR number and a repository variable Through commit statuses and its own comment
Cloudflare Pages A branch alias on pages.dev, derived from the branch name Copy the format from a past deployment, then build it from github.head_ref Through check runs and a comment
Vercel A unique URL per deployment, plus a branch alias Use the deployment_status event and its environment_url Yes, as GitHub deployments
Your own preview environments Whatever your deploy script creates Have the deploy job write it to $GITHUB_OUTPUT Only if your script creates a deployment

This post uses Netlify's pattern in the workflow, because the URL is fixed per pull request. For Cloudflare Pages, check the alias of one real preview before you build it in a workflow: long and unusual branch names get shortened and cleaned up. For Vercel, the deployment_status approach in the integration page avoids building the URL at all.

Step 2: stamp each build with its commit

Make the build write the commit SHA into a static file. Each host exposes the SHA as an environment variable during the build:

Host Build variable with the commit SHA
Netlify COMMIT_REF
Cloudflare Pages CF_PAGES_COMMIT_SHA
Vercel VERCEL_GIT_COMMIT_SHA

Add one step after your normal build command. For a site that builds into dist/:

# Netlify build command (site settings or netlify.toml)
npm run build && echo "$COMMIT_REF" > dist/version.txt

Write the file into the directory the host publishes, not the source directory, or the file will not be served. The file is public on every preview and on production. A commit SHA is not a secret in most repositories; if it is in yours, write a hash of it and hash the head SHA the same way in the wait script.

A note on GITHUB_SHA: in a pull_request workflow it is the SHA of a temporary merge commit, not the commit the host built. Use github.event.pull_request.head.sha, which is what the workflow below passes as HEAD_SHA.

Step 3: wait for the right commit

The wait script polls /version.txt with a doubling delay (5, 10, 20, 40, then every 60 seconds) until the file returns the head SHA. It exits non-zero when it gives up, so the job fails with a clear message instead of posting the wrong images.

// wait-for-preview.mjs: poll the preview until it serves the pull request's commit.
// Plain fetch, so polling costs no screenshot credits. Node 22+, no dependencies.
//   PREVIEW_URL=https://deploy-preview-42--my-site.netlify.app HEAD_SHA=<sha> node wait-for-preview.mjs
const { PREVIEW_URL, HEAD_SHA } = process.env;
const TIMEOUT_S = Number(process.env.WAIT_TIMEOUT_S ?? '900');
const headers = {};
// Vercel deployment protection: automation bypass secret, sent only to the preview.
if (process.env.VERCEL_BYPASS) headers['x-vercel-protection-bypass'] = process.env.VERCEL_BYPASS;

const deadline = Date.now() + TIMEOUT_S * 1000;
let delay = 5;
for (;;) {
  let seen = '';
  try {
    const res = await fetch(new URL('/version.txt', PREVIEW_URL), { headers, cache: 'no-store' });
    if (res.status === 401 || res.status === 403) {
      console.error(`Preview answered ${res.status}: it is protected. Set VERCEL_BYPASS or use a public preview.`);
      process.exit(1);
    }
    seen = res.ok ? (await res.text()).trim() : `HTTP ${res.status}`;
  } catch (err) {
    seen = err.cause?.code ?? err.message; // DNS not ready yet, connection refused
  }
  if (seen === HEAD_SHA) {
    console.log(`Preview serves ${HEAD_SHA.slice(0, 7)}`);
    break;
  }
  if (Date.now() + delay * 1000 > deadline) {
    console.error(`Gave up after ${TIMEOUT_S}s. Last answer: ${seen.slice(0, 80)}`);
    process.exit(1);
  }
  console.log(`Waiting ${delay}s (preview says: ${seen.slice(0, 40)})`);
  await new Promise((r) => setTimeout(r, delay * 1000));
  delay = Math.min(delay * 2, 60);
}

Design choices worth keeping if you rewrite it:

  • Poll a file, not a page. A page can be served from a CDN cache. A small text file fetched with cache: 'no-store' and compared exactly gives a yes or no.
  • 401 and 403 stop at once. Waiting does not fix a protected preview, so failing in the first second saves fifteen minutes of runner time.
  • A DNS error is a normal state. The first preview of a pull request has no hostname until its first build finishes, so the script treats a failed lookup as "not yet".
  • The polling is free. It runs on the runner with fetch. Screenshot credits are spent only once the preview is right.

Step 4: render production and preview side by side

Once the preview is ready, the second script renders each page twice with identical options, once on production and once on the preview. It asks for JSON (Accept: application/json), so each response carries image.url, a public CDN link, instead of the image bytes. The comment embeds those links directly. Nothing is uploaded from the runner.

// pr-shots.mjs: render each page on production and on the preview, then create or
// update one pull request comment with a before/after table. Node 22+, no dependencies.
// Env: RS_API_KEY, PROD_URL, PREVIEW_URL, PAGES ("/,/pricing"), GITHUB_TOKEN,
//      GITHUB_REPOSITORY, PR_NUMBER, HEAD_SHA, optional VERCEL_BYPASS.
const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const GH = process.env.GITHUB_API_URL ?? 'https://api.github.com';
const env = process.env;
const MARKER = '<!-- rs-preview-screenshots -->';
const pages = (env.PAGES ?? '/').split(',').map((p) => p.trim()).filter(Boolean);

async function render(url, isPreview, attempt = 1) {
  const body = {
    url,
    preset: 'desktop_hd',
    viewport: { scale: 1 },
    output: { format: 'webp', quality: 80 },
    block: { cookie_banners: true, chat_widgets: true },
    browser: { reduced_motion: true },
    wait: { until: 'networkidle' },
    cache: { ttl: 2592000 }, // comment images stay up for 30 days
  };
  if (isPreview && env.VERCEL_BYPASS) {
    body.network = { headers: { 'x-vercel-protection-bypass': env.VERCEL_BYPASS } };
  }
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${env.RS_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (res.status === 429 && attempt < 4) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? '5') * 1000));
    return render(url, isPreview, attempt + 1);
  }
  if (!res.ok) return { error: `${json.error?.code ?? res.status}` };
  return { src: json.image.url };
}

const cell = (shot) => (shot.error ? `failed: \`${shot.error}\`` : `<img src="${shot.src}" width="400">`);
const rows = [];
let failed = 0;
for (const path of pages) {
  const [before, after] = await Promise.all([
    render(new URL(path, env.PROD_URL).href, false),
    render(new URL(path, env.PREVIEW_URL).href, true),
  ]);
  if (after.error) failed++;
  rows.push(`| \`${path}\` | ${cell(before)} | ${cell(after)} |`);
  console.log(`${path}: before ${before.error ?? 'ok'}, after ${after.error ?? 'ok'}`);
}

const comment = [
  MARKER,
  `### Preview screenshots for ${env.HEAD_SHA.slice(0, 7)}`,
  '',
  '| Page | Production | This PR |',
  '|---|---|---|',
  ...rows,
  '',
  `<sub>Preview: ${env.PREVIEW_URL}. Images expire after 30 days.</sub>`,
].join('\n');

// One comment per PR: find ours by the marker, edit it, or create it.
async function gh(path, init = {}) {
  const res = await fetch(`${GH}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28' },
  });
  if (!res.ok) throw new Error(`GitHub ${init.method ?? 'GET'} ${path}: ${res.status} ${await res.text()}`);
  return res.json();
}
const issue = `/repos/${env.GITHUB_REPOSITORY}/issues/${env.PR_NUMBER}`;
const existing = (await gh(`${issue}/comments?per_page=100`)).find((c) => c.body?.startsWith(MARKER));
if (existing) {
  await gh(`/repos/${env.GITHUB_REPOSITORY}/issues/comments/${existing.id}`, { method: 'PATCH', body: JSON.stringify({ body: comment }) });
  console.log(`Updated comment ${existing.id}`);
} else {
  const created = await gh(`${issue}/comments`, { method: 'POST', body: JSON.stringify({ body: comment }) });
  console.log(`Created comment ${created.id}`);
}
if (failed) process.exitCode = 1;

What each option is for:

Option Value Reason
preset desktop_hd A 1920 x 1080 viewport, wide enough to show desktop layouts. The presets reference lists the others
viewport.scale 1 The comment shows 400-pixel-wide images; extra pixel density only makes the files larger
output WebP, quality 80 For people to look at. Use PNG only when a script compares pixels
block cookie banners, chat widgets Consent dialogs cover the part of the page under review
browser.reduced_motion true Animations that respect the setting rest in their final state, so both sides match
cache.ttl 2592000 The CDN links in the comment work for 30 days, the maximum
network.headers bypass secret, preview only Production gets no header it does not need. Custom headers go only to the target's origin; see the network options

Two details in the script are on purpose. The production and preview renders for a page run at the same moment, so the pair shows the same third-party content and the same "today". And a failed production render does not fail the job, while a failed preview render does: the job is about the change, not about production.

The comment is found again by a hidden HTML marker at its start, so each push edits one comment instead of adding a new one. That keeps the pull request readable after twenty pushes.

Step 5: the workflow

Save the scripts in .github/scripts/ and the workflow as .github/workflows/preview-screenshots.yml:

name: Preview screenshots

on:
  pull_request:
    types: [opened, synchronize, reopened]

# A new push cancels the run for the previous commit of the same PR.
concurrency:
  group: preview-screenshots-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  pull-requests: write

jobs:
  screenshots:
    # Fork PRs get no secrets, so skip them instead of failing.
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 25
    env:
      PREVIEW_URL: https://deploy-preview-${{ github.event.pull_request.number }}--${{ vars.NETLIFY_SITE_NAME }}.netlify.app
      HEAD_SHA: ${{ github.event.pull_request.head.sha }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Wait until the preview serves this commit
        run: node .github/scripts/wait-for-preview.mjs

      - name: Render before/after and update the PR comment
        env:
          RS_API_KEY: ${{ secrets.RS_API_KEY }}
          GITHUB_TOKEN: ${{ github.token }}
          PROD_URL: ${{ vars.PROD_URL }}
          PAGES: /,/pricing,/docs
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: node .github/scripts/pr-shots.mjs

Set these in the repository settings: the secret RS_API_KEY (a key that starts with rs_live_, from your dashboard after you sign up), and the variables NETLIFY_SITE_NAME and PROD_URL. The CLI in CI guide covers secret handling in more detail if you prefer a shell step.

Why each part is there:

  • concurrency with cancel-in-progress. Push three commits in a minute and only the last run continues. Without it, three runs wait on the preview and the oldest may post last, over the newer images.
  • The fork condition. Fork pull requests run without repository secrets, so RS_API_KEY would be empty. Skipping is better than a red check on every outside contribution.
  • timeout-minutes: 25 is above the wait script's 15-minute limit, so the script's own message, not a generic cancel, explains a slow build.
  • pull-requests: write lets the default token comment on the pull request. Nothing else needs write access.

What it costs

Each page is two renders per push: production and preview. Polling is free, and so is any render that fails.

Team activity Pages Renders per month Plan that covers it
40 pushes to PRs a month 3 240 Starter: 2,500 credits for $10
200 pushes a month 3 1,200 Starter
400 pushes a month 6 4,800 Growth: 12,000 credits for $40

Production pages do not change between pushes of the same afternoon. To render them once and reuse them, add cache: { available: true, ttl: 3600 } to the production request only. A cache hit costs 0 credits, and the caching reference explains when a request counts as the same. That cuts the bill close to half for busy teams. Current prices are on the pricing page.

The rate limit is per account, shared by every key and every repository on it. Six pages make twelve requests in a few seconds; a monorepo that runs this for several sites at once can hit the per-minute limit. The script waits for Retry-After on a 429. The rate limits reference lists the numbers per plan.

From pictures to a pass or fail

A before/after table asks a person to spot the difference. To make the build fail by itself when a page changes more than expected, request PNG instead of WebP and compare the two images with pixelmatch. The visual regression testing use case has that script, with the settings that keep false differences low. To check text instead of pixels, such as "the pricing page still shows a price", add options.fail_if_missing to the preview request; the content checks guide has recipes.

Troubleshooting

Symptom Cause Fix
Wait step times out, last answer HTTP 404 version.txt is not in the published directory Write it into the build output (dist/, out/, public/ after the copy), then check /version.txt on a preview by hand
Wait step times out, last answer is an older SHA The script compares with GITHUB_SHA, or the host built another commit Use github.event.pull_request.head.sha; check that the host builds the PR head, not a merge
Wait step exits at once with 401 or 403 Preview protection is on On Vercel, set VERCEL_BYPASS to the automation bypass secret, as a secret. Other hosts: make previews public or use network.auth
Wait step says ENOTFOUND until timeout The URL pattern is wrong, so the host never answers Open one preview by hand and compare its hostname with the workflow's PREVIEW_URL
Comment images show a login page The bypass header reached the wait script but not the render Pass VERCEL_BYPASS to the render step's env as well
A second comment appears Someone edited the comment and removed the marker, or the PR has more than 100 comments Keep the first line of the comment; page through comments if your PRs get long
GitHub POST ... 403 The token cannot write to the pull request Check permissions: pull-requests: write in the workflow, and that the organization lets workflows request write access
Production column says failed: timeout Production is slow or blocks automated browsers The job still passes. Add wait.timeout or check the timeout error page
Every render fails with invalid_api_key The secret is missing, revoked or a test value Check the RS_API_KEY secret; fork runs are skipped on purpose
Images in the comment break after a month The CDN links expired with cache.ttl Push again to refresh, or keep copies as a workflow artifact

Try it on one page first: get a free API key, add the secret and the two variables, and open a pull request that changes one heading.

Related in this topic