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
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.
Comparing the Two
| Measure | A: Cloudflare Pages | B: Subfolders on Pages |
|---|---|---|
| Push to preview live | 1 min 35 s | 2 min 40 s (includes Pages republish) |
| Base path handling | None | Required per build |
| Concurrent previews | Unlimited, independent | Shared branch; occasional push conflicts |
| Cleanup | Host deployments + scheduled delete | Workflow on PR close |
| Extra accounts or services | Cloudflare account | None |
| Previews indexed by default | No (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.
| Measure | Before | After | Source |
|---|---|---|---|
| Docs PRs reviewed with the rendered page | ~30% (local runs) | 96% | Review comments mentioning preview |
| Median time to first review | 19 h | 7 h | GitHub insights |
| Post-merge fixes for layout/rendering issues | 11 per quarter | 2 per quarter | PR history |
| Preview cost | — | $0 (Cloudflare free) | Billing |
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
noindexby 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.
Related
- Parent: Preview Environments for Pull Requests — running previews well.
- Automating Preview Deploy Pipelines with GitHub Actions — previews on any host.
- Commenting on Preview Deploys — feedback on the rendered page.
- Deploying to GitHub Pages with Actions — the production side.
- Branch Deploy Controls on Cloudflare Pages — the preview host's branch settings.
Filed underHow-tosGitHub PagesGitHub Actions