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.mjsagainst a local server whose/version.txtanswered 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 ranpr-shots.mjstwice 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:
concurrencywithcancel-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_KEYwould be empty. Skipping is better than a red check on every outside contribution. timeout-minutes: 25is above the wait script's 15-minute limit, so the script's own message, not a generic cancel, explains a slow build.pull-requests: writelets 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 |
Related guides
- Website screenshots in GitHub Actions: the
deployment_statusversion with a reusable workflow, best for Vercel. - Visual regression testing: fail the build on pixel changes between production and the preview.
- Run the CLI in CI: pinned install of the
rsCLI and exit codes. - Network options: headers, cookies and HTTP authentication for protected previews.
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
- Website Screenshots in GitHub Actions, with PR Comments A reusable GitHub Actions workflow that captures preview deployments, uploads the images as an artifact and posts them in a pull request comment.
- Visual Regression Testing API for CI Screenshots Use a screenshot API as the capture step of visual regression testing. Render production and the PR preview with the same settings, then diff them in CI.
- Post-Deploy Screenshot Check in GitHub Actions Verify a deployment with screenshots. Render key pages after each production deploy, fail on missing or forbidden text, and see each capture in the job summary.
- Best Visual Regression Testing Tools (2026) Playwright, BackstopJS, Percy, Chromatic, Applitools, Argos and DIY pixelmatch compared, with a flake test we ran and prices checked in October 2026.
- Website Design Audit Screenshots on One Board Capture every page of a site on desktop, tablet and phone, in light and dark mode, and lay the screenshots out on one HTML audit board for a design review.