Back to Blog
GitHub Actions February 26, 2026 · 5 min read

How to Post Screenshot Previews to Every GitHub PR Automatically

Screenshot your preview deployment on every PR and post the images as a comment — no Percy, no Chromatic, no per-seat pricing. Just a GitHub Actions job and two API calls.

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.

Get Started Free

100 requests/month, no credit card

Post visual PR previews automatically on every pull request — works with Vercel, Netlify, Railway, and any preview deployment platform.

Get Your Free API Key →