DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to post screenshot previews to every GitHub PR automatically

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,
              });
            }
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)