How to Post Screenshot Previews to Every GitHub PR Automatically
Tools like Percy and Chromatic give you visual PR previews, but they're priced per screenshot and require an SDK integration. For many teams, the pricing doesn't justify the setup.
Here's the lightweight alternative: when a PR is opened or updated, screenshot the preview deployment, and post the images directly to the PR as a comment. No SDK, no per-seat pricing — just a GitHub Actions job and two API calls.
Prerequisites
- A preview deployment that spins up per PR (Vercel, Netlify, Railway, Render, or self-hosted)
- The preview URL available as an environment variable or output from your deploy step
GitHub Actions workflow
# .github/workflows/pr-preview.yml
name: PR screenshot preview
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
screenshot-preview:
runs-on: ubuntu-latest
# Only run if a preview URL is available
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v4
- name: Wait for Vercel preview
id: vercel
run: |
# Poll until the preview URL is live (max 3 minutes)
PR_NUMBER=${{ github.event.pull_request.number }}
PREVIEW_URL="https://${{ github.event.repository.name }}-git-${{ github.head_ref }}-yourteam.vercel.app"
for i in $(seq 1 18); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL")
if [ "$STATUS" = "200" ]; then
echo "url=$PREVIEW_URL" >> $GITHUB_OUTPUT
echo "Preview live: $PREVIEW_URL"
break
fi
echo "Waiting for preview... ($i/18)"
sleep 10
done
- name: Take screenshots
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
PREVIEW_URL: ${{ steps.vercel.outputs.url }}
run: node scripts/pr-screenshots.js
- name: Upload screenshots
id: upload
uses: actions/upload-artifact@v4
with:
name: pr-screenshots
path: screenshots/
- name: Post PR comment
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.vercel.outputs.url }}
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('screenshots/results.json'));
const previewUrl = process.env.PREVIEW_URL;
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const rows = results.map(r =>
`| ${r.name} | [view](${previewUrl}${r.path}) |`
).join('\n');
const body = [
'## Visual preview',
'',
`Preview: ${previewUrl}`,
'',
'| Page | Link |',
'|------|------|',
rows,
'',
`[Download screenshots](${runUrl}) — generated by PageBolt`,
].join('\n');
// Find existing preview comment (update instead of creating a new one)
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Visual preview')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
Screenshot script
// scripts/pr-screenshots.js
import fs from "fs/promises";
const PREVIEW_URL = process.env.PREVIEW_URL;
const PAGEBOLT_API_KEY = process.env.PAGEBOLT_API_KEY;
// Pages to screenshot on every PR
const PAGES = [
{ name: "Home", path: "/" },
{ name: "Pricing", path: "/pricing" },
{ name: "Login", path: "/login" },
{ name: "Docs", path: "/docs" },
];
await fs.mkdir("screenshots", { recursive: true });
const results = [];
for (const page of PAGES) {
const url = `${PREVIEW_URL}${page.path}`;
console.log(`Screenshotting ${page.name}: ${url}`);
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
fullPage: false,
blockBanners: true,
blockAds: true,
}),
});
if (!res.ok) {
console.error(`Failed: ${page.name} — ${res.status}`);
continue;
}
const buffer = Buffer.from(await res.arrayBuffer());
const filename = `${page.name.toLowerCase().replace(/\s+/g, "-")}.png`;
await fs.writeFile(`screenshots/${filename}`, buffer);
results.push({ name: page.name, path: page.path, filename });
console.log(` ✓ Saved ${filename} (${buffer.length} bytes)`);
}
await fs.writeFile("screenshots/results.json", JSON.stringify(results, null, 2));
console.log(`\nScreenshots complete: ${results.length}/${PAGES.length} pages`);
Mobile preview too
Screenshot at mobile viewport alongside desktop:
const VIEWPORTS = [
{ label: "desktop", device: null },
{ label: "mobile", device: "iphone_14_pro" },
];
for (const page of PAGES) {
for (const viewport of VIEWPORTS) {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: { "x-api-key": PAGEBOLT_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
url: `${PREVIEW_URL}${page.path}`,
...(viewport.device && { viewportDevice: viewport.device }),
blockBanners: true,
}),
});
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(`screenshots/${page.name.toLowerCase()}-${viewport.label}.png`, buffer);
}
}
With Netlify (deploy-url from action output)
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v3
with:
publish-dir: dist
github-token: ${{ secrets.GITHUB_TOKEN }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
- name: Take screenshots
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
PREVIEW_URL: ${{ steps.netlify.outputs.deploy-url }}
run: node scripts/pr-screenshots.js
With Railway or Render (poll for the URL)
- name: Get preview URL
id: preview
run: |
# Railway: use the PR environment URL pattern
BRANCH=$(echo "${{ github.head_ref }}" | tr '/' '-' | tr '[:upper:]' '[:lower:]')
echo "url=https://yourapp-pr-${{ github.event.pull_request.number }}.up.railway.app" >> $GITHUB_OUTPUT
The result: every PR gets a bot comment with links to each page on the preview deployment and a downloadable screenshot archive. Reviewers can see the visual state of the app before merging without cloning the branch.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)