Skip to content

Preview Deployments

intermediate18 min read

One URL Per Pull Request

Imagine you open a PR that redesigns the course navigation. A reviewer reads your diff, squints at the JSX, and says "looks good to me." Three days later it lands in production and the mobile layout is completely broken. Nobody tested it visually because nobody had an easy way to see it.

Preview deployments solve this. Every PR gets its own live URL — a fully deployed version of your app with that PR's changes. Reviewers click a link, see the actual result, and catch visual bugs before they merge. No local setup, no "pull the branch and run it," no guessing from diffs.

Mental Model

Think of preview deployments like a fitting room in a clothing store. You would not buy a suit based on looking at the fabric swatch and stitching pattern (the diff). You try it on first (the preview). Preview deployments give every PR its own fitting room — a live, interactive environment where you can see exactly how the changes look and behave before committing to the purchase (merging).

How Preview Deployments Work

The core concept is simple: when a PR is opened or updated, a CI job builds the app, deploys it to a temporary URL, and posts that URL as a comment on the PR.

The Platform Approach: Vercel, Netlify, Cloudflare Pages

If you deploy on Vercel, Netlify, or Cloudflare Pages, preview deployments come out of the box. Connect your Git repository, and every push to a PR branch automatically gets its own deployment.

Vercel generates URLs like your-project-git-branch-name-org.vercel.app. Each deployment is immutable — even after the PR merges, the preview URL still works, which is useful for post-merge verification.

Netlify follows a similar pattern with URLs like deploy-preview-42--your-project.netlify.app where 42 is the PR number.

Cloudflare Pages uses the format commit-hash.your-project.pages.dev and supports branch-based previews with custom domains per branch.

All three platforms handle the hard parts automatically: building from the PR branch, provisioning a unique URL, setting up SSL, and posting the URL back to the PR as a deployment status check.

Quiz
Your team uses Vercel for preview deployments. A developer pushes 3 commits to the same PR within 5 minutes. How many preview deployments are created?

Building Preview Deploys with GitHub Actions

When you are not using a platform with built-in previews, you can build the entire flow yourself with GitHub Actions. Here is a complete workflow that builds a Next.js app, deploys it, and comments on the PR:

name: Preview Deploy

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

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build
        env:
          NEXT_PUBLIC_API_URL: https://api-preview-${{ github.event.pull_request.number }}.example.com

      - name: Deploy to Cloudflare Pages
        id: deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy .next --project-name=my-project --branch=pr-${{ github.event.pull_request.number }}

      - name: Comment preview URL on PR
        uses: actions/github-script@v7
        with:
          script: |
            const url = '${{ steps.deploy.outputs.deployment-url }}';
            const body = `### Preview Deployment\n\nThis PR has been deployed to:\n${url}\n\nCommit: ${context.sha.substring(0, 7)}`;

            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const existing = comments.find(c =>
              c.body.includes('### Preview Deployment') &&
              c.user.type === 'Bot'
            );

            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,
              });
            }

A few important details:

  • Concurrency group by PR number — If the developer pushes again before the previous deploy finishes, the old run is cancelled. No wasted compute.
  • Comment update logic — Instead of spamming a new comment for every push, the script finds its previous comment and updates it. Reviewers see one clean comment, always pointing to the latest deploy.
  • PR-specific environment variables — The NEXT_PUBLIC_API_URL can point to a PR-specific backend, enabling full-stack previews.

Environment Variables Per Preview

This is where preview deployments get truly powerful. Instead of every preview pointing to your production API, you can isolate them:

- name: Build
  run: pnpm build
  env:
    NEXT_PUBLIC_API_URL: ${{ secrets.PREVIEW_API_URL }}
    NEXT_PUBLIC_FEATURE_FLAGS: "new-nav=true,dark-mode=beta"
    NEXT_PUBLIC_ANALYTICS_ID: ""

Setting NEXT_PUBLIC_ANALYTICS_ID to empty prevents preview traffic from polluting your production analytics — a common mistake that corrupts real user data with reviewer clicks.

Environment variable leaks

Remember that NEXT_PUBLIC_* variables are embedded into the client bundle at build time. Anyone who visits your preview URL can extract them from the JavaScript source. Never put actual secrets in NEXT_PUBLIC_ variables — not even for previews.

Quiz
Your preview deployment uses NEXT_PUBLIC_API_URL pointing to a staging API. A reviewer visits the preview and creates test data. Where does that test data live?

Cleanup on PR Close

Preview deployments should not live forever. When a PR is merged or closed, clean up the deployment to avoid accumulating orphaned environments and unnecessary hosting costs.

name: Cleanup Preview

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Delete preview deployment
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deployment delete --project-name=my-project --branch=pr-${{ github.event.pull_request.number }}

The pull_request: types: [closed] trigger fires when a PR is merged or closed. This is the right place for cleanup because it covers both outcomes.

Common Trap

If you use Vercel or Netlify, preview deployments are not automatically deleted when PRs close. They persist as immutable snapshots. This is usually fine because they cost nothing to keep around. But if your previews connect to external services (databases, APIs with rate limits), those connections stay live until the preview is explicitly removed or the service times out.

Visual Regression Testing on Previews

Preview deployments unlock one of the most valuable CI checks: visual regression testing. Instead of comparing screenshots from a local dev server, you can capture screenshots from the actual deployed preview — the exact environment your users will see.

name: Visual Regression

on:
  deployment_status:

jobs:
  visual-test:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Run Playwright visual tests
        run: pnpm playwright test --project=visual
        env:
          BASE_URL: ${{ github.event.deployment_status.target_url }}

      - name: Upload visual diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: test-results/
          retention-days: 7

The deployment_status event fires after a deployment succeeds or fails. By checking state == 'success', you run visual tests only after the preview is live and accessible. The preview URL is available in deployment_status.target_url.

Choosing a visual regression tool

Playwright screenshots — Built-in, free, runs in CI. You write assertions like await expect(page).toHaveScreenshot(). Playwright generates and compares pixel-level diffs automatically. Best for teams starting out.

Chromatic — SaaS tool from the Storybook team. Captures screenshots of every Storybook story and compares them. Great if you already use Storybook — you get component-level visual testing without writing page-level tests.

Percy (BrowserStack) — SaaS visual testing platform. Captures full-page screenshots across browsers and viewports. Posts comparison results as PR checks. Higher cost but handles cross-browser visual testing that Playwright alone cannot.

For most frontend teams, start with Playwright's built-in screenshot comparison. Graduate to Chromatic or Percy when you need cross-browser coverage or the approval workflow they provide.

Quiz
Your visual regression test captures a screenshot of the homepage on every PR. A developer changes the hero section copy. The visual test fails because the text is different. Is this the right behavior?
What developers doWhat they should do
Posting a new PR comment for every push to the preview
Multiple comments clutter the PR and make it hard to find the latest URL
Find and update the existing bot comment
Using production API keys in preview environment variables
Reviewer actions on previews should not affect production data or consume production rate limits
Use staging or preview-specific API keys
Not cleaning up preview deployments when PRs close
Orphaned previews waste hosting resources and may keep external service connections alive
Add a cleanup workflow triggered on pull_request closed
Running visual regression tests against localhost
The deployed environment may render differently due to CDN, compression, or edge functions
Run them against the deployed preview URL

The Preview Deployment Mindset

Preview deployments are not just a convenience — they fundamentally change how your team reviews code. Instead of reading diffs and imagining what the result looks like, reviewers interact with the actual result. This catches an entire category of bugs that code review alone misses: layout issues, responsive breakpoints, animation timing, accessibility problems that only manifest visually.

The best teams treat previews as a required review step. The PR template includes "Tested on preview: [link]" as a checkbox. Reviewers click the link before approving. Visual regression tests run against the preview automatically. By the time a PR merges, it has been seen, clicked, and verified in a production-like environment.