Pull Request Previews for GitHub Pages Sites

GitHub Pages hosts one version of a site: whatever the default branch or the Pages workflow last deployed. That is fine for production and leaves a gap that teams on Netlify, Vercel and Cloudflare take for granted — a live preview of every pull request, so reviewers can see the change rendered instead of reading Markdown diffs. Documentation teams in particular feel the absence: reviewing a reorganised navigation, a new diagram or a table layout from a diff is guesswork.

This guide adds pull request previews to a VitePress documentation site that deploys production to GitHub Pages. It sets up the two practical approaches — deploying previews to a separate host with preview support, and publishing previews into subfolders of a second Pages site — compares them, and covers comments, base paths, forks and cleanup. It belongs to Preview Environments for Pull Requests; the production deploy is in Publishing Any Generator to GitHub Pages With Actions.

Two Approaches

Two ways to give a GitHub Pages site pull request previews Two panels. Option A deploys each pull request build to a host with built-in previews, such as Cloudflare Pages, with a unique URL per deploy, no base path changes and automatic cleanup by the host. Option B publishes each build into a subfolder of a second GitHub Pages site, keeping everything on GitHub but requiring a base path per preview, a shared branch and explicit cleanup. Where previews can live A. Preview host unique URL per deploy no base path changes host cleans up old previews simplest B. Subfolders on a 2nd Pages site /pr-123/ per pull request base path per build cleanup on PR close all on GitHub
Production stays on GitHub Pages in both cases; only previews move.

Option A: Previews on Cloudflare Pages

A Cloudflare Pages project with no Git connection serves as the preview host; GitHub Actions builds the site and uploads it with Wrangler as a branch deploy:

name: preview
on: pull_request
permissions: { contents: read, pull-requests: write }
jobs:
  preview:
    if: github.event.pull_request.head.repo.full_name == github.repository   # no secrets for forks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci && npm run docs:build
        env: { BASE_PATH: '' }
      - id: deploy
        run: |
          out=$(npx wrangler@4 pages deploy docs/.vitepress/dist --project-name acme-docs-previews --branch "pr-${{ github.event.number }}" 2>&1)
          echo "$out"; echo "url=$(echo "$out" | grep -oE 'https://[^ ]+\.pages\.dev' | tail -1)" >> "$GITHUB_OUTPUT"
        env: { CLOUDFLARE_API_TOKEN: '${{ secrets.CF_PREVIEW_TOKEN }}', CLOUDFLARE_ACCOUNT_ID: '${{ secrets.CF_ACCOUNT_ID }}' }
      - uses: marocchino/sticky-pull-request-comment@v2
        with: { header: preview, message: "Preview: ${{ steps.deploy.outputs.url }}" }

Each push to the pull request creates a new deployment and updates the branch alias pr-123.acme-docs-previews.pages.dev, so the comment's URL always shows the latest commit. The site builds with an empty base path, because each preview has its own host. Cloudflare keeps deployments; a scheduled job deletes those older than 30 days, as in Cleaning Up Stale Preview Deployments. The same pattern works with netlify deploy --alias pr-123 or Surge.

Option B: Subfolders of a Second Pages Site

For teams that want everything on GitHub, a second repository (acme/docs-previews) has Pages enabled from its gh-pages branch, and each pull request's build is pushed into a folder:

      - run: npm ci && npm run docs:build
        env: { BASE_PATH: '/docs-previews/pr-${{ github.event.number }}' }
      - uses: peaceiris/actions-gh-pages@v4
        with:
          personal_token: ${{ secrets.PREVIEWS_TOKEN }}
          external_repository: acme/docs-previews
          publish_branch: gh-pages
          publish_dir: docs/.vitepress/dist
          destination_dir: pr-${{ github.event.number }}
          keep_files: true

The site must be built with the preview's base path — VitePress's base reads BASE_PATH — or every asset and link breaks. keep_files: true keeps other pull requests' folders. A second workflow on pull_request: types: [closed] deletes the folder by pushing a commit that removes it.

Lifecycle of a subfolder preview on a second GitHub Pages site Five steps. A pull request is opened or updated. The workflow builds the site with base path slash docs-previews slash pr number. It pushes the output to the pr number folder on the previews repository's gh-pages branch, keeping other folders. Pages republishes and the workflow comments the URL. When the pull request closes, a cleanup workflow removes the folder. Subfolder preview lifecycle PR opened/ updated trigger Build with base path /docs-previews/ pr-123 Push to folder keep other folders Pages republish comment URL PR closed remove folder
Every preview update republishes the whole previews site, which took 40 to 70 seconds on GitHub Pages.

Comparing the Two

MeasureA: Cloudflare PagesB: Subfolders on Pages
Push to preview live1 min 35 s2 min 40 s (includes Pages republish)
Base path handlingNoneRequired per build
Concurrent previewsUnlimited, independentShared branch; occasional push conflicts
CleanupHost deployments + scheduled deleteWorkflow on PR close
Extra accounts or servicesCloudflare accountNone
Previews indexed by defaultNo (noindex on pages.dev previews)Yes, unless headers or meta added

The team chose A. B's shared branch caused push conflicts when two pull requests updated at the same moment (twice in the first week), fixed with a concurrency group but at the cost of serialising previews. B also needed a <meta name="robots" content="noindex"> injected into preview builds, since GitHub Pages cannot set headers — see Fixing Preview Deploys Leaking Into Search Results.

Pull Requests From Forks

The repository is public and accepts documentation contributions from forks. Fork pull requests do not get secrets in the pull_request event, so the deploy step cannot run. Running deploys under pull_request_target would give the fork's code access to secrets if it were built in that job. The safe split: the pull_request workflow builds the fork's code without secrets and uploads the output as an artifact; a second workflow triggered by workflow_run downloads the artifact — files only, no code execution — and deploys it with the token. Maintainers see a preview for outside contributions without the fork ever running with credentials.

Making Previews Useful to Reviewers

A preview URL alone sends reviewers to the home page, which is rarely what changed. The comment step now lists the pages the pull request touched, computed from the changed Markdown files mapped to their output paths, with a direct preview link for each:

const changed = (await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: pr }))
  .map((f) => f.filename).filter((f) => f.startsWith('docs/') && f.endsWith('.md'))
  .map((f) => f.replace(/^docs\//, '/').replace(/(index)?\.md$/, ''));
const body = `Preview: ${url}\n\nChanged pages:\n` + changed.map((p) => `- [${p}](${url}${p})`).join('\n');

Reviewers click straight to the changed pages. For pull requests that change the theme or navigation, the comment instead links a fixed set of five representative pages, since every page is affected.

Previews also run the same checks as production builds — links, HTML validation and accessibility — against the preview URL, and their results appear as checks on the pull request, so reviewers see both the rendered change and whether it passed.

Previews for Every Push or Only on Request?

Building a preview for every push to every pull request uses CI minutes, and documentation pull requests often get many small pushes. The team limited previews to pull requests that change files under docs/ or the theme, through a paths filter on the workflow trigger, and skips previews for draft pull requests until they are marked ready for review. Writers who want a preview of a draft add a preview label, which a second trigger on labeled events picks up. Together these cut preview builds by about half — from roughly 300 to 150 a month — without anyone reporting a missing preview when they needed one. At about 1.5 minutes each, the remaining builds use around 225 CI minutes a month, well within the repository's allowance.

Measured Impact

Before previews, reviewers checked out the branch and ran the dev server locally, or reviewed diffs only.

MeasureBeforeAfterSource
Docs PRs reviewed with the rendered page~30% (local runs)96%Review comments mentioning preview
Median time to first review19 h7 hGitHub insights
Post-merge fixes for layout/rendering issues11 per quarter2 per quarterPR history
Preview cost—$0 (Cloudflare free)Billing
Time to first review and post-merge rendering fixes before and after previews Paired bars. Median time to first review went from 19 hours to 7 hours. Post-merge fixes for layout and rendering issues went from 11 to 2 per quarter. Review speed and rework Before previews With previews Hours to first review 19 7 Rendering fixes per quarter 11 2
GitHub pull request data for the docs repository, one quarter before and one after.

Pitfalls & Rollback

  • Building subfolder previews without the base path. Every asset 404s.
  • Running fork code with deploy secrets. Build without secrets; deploy the artifact separately.
  • Indexable previews. Add noindex by header or meta tag.
  • Never cleaning up. Delete old previews on close or on a schedule.
  • Rollback: the preview workflow is independent of production; disabling it affects only previews.

Conclusion

GitHub Pages has no previews, but GitHub Actions can provide them: deploy each pull request's build to a host with built-in previews, or into a subfolder of a second Pages site with the right base path. The first was simpler and faster for this VitePress site, and with fork-safe deploys and automatic comments it cut time to first review from 19 hours to 7 and post-merge rendering fixes by 80%.

FAQ

Does GitHub Pages support pull request previews?

Not natively. A Pages site has one deployment, from the default branch or the configured workflow. Previews need either a second host that supports multiple deployments, such as Cloudflare Pages, Netlify or Surge, or a scheme that publishes each pull request's build into a subfolder of a separate preview Pages site.

Which preview approach is simplest?

Deploying each pull request's build to a host with built-in preview URLs, such as Cloudflare Pages or Netlify, using their CLI from GitHub Actions. Production stays on GitHub Pages; previews live elsewhere. The subfolder approach on a second Pages site keeps everything on GitHub but needs base path handling and cleanup.

How do base paths work for subfolder previews?

Each preview lives under a path like slash pr dash 123, so the site must be built with that base path, the same way project sites are built with the repository name as base. The workflow passes the preview path to the generator's base or path prefix setting at build time.

Are previews from forks safe?

Only if the build runs without secrets. Pull requests from forks do not receive repository secrets in the pull_request event. Using pull_request_target to deploy them gives the fork's code access to secrets unless the build and deploy are separated so untrusted code never runs with credentials.

Filed underHow-tosGitHub PagesGitHub Actions