Preview Deployments
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.
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.
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_URLcan 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.
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.
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.
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.
| What developers do | What 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.