Preview Deploys for Monorepos
Organisations that run several static sites increasingly keep them in one repository: the docs, the marketing site, the blog and the design system's documentation share a component library, a design tokens package, a Markdown configuration and CI. The monorepo makes shared changes easy — update a button component once, see it everywhere — and makes previews harder. A pull request that edits a blog post should build and preview the blog, not all four sites. A pull request that changes the shared component library should preview every site that uses it. And reviewers need to find the right preview among several.
This guide sets up previews for a monorepo with four static sites and three shared packages: detecting affected sites from the dependency graph, building only those, deploying each to its own preview URL, and posting one combined comment. It is part of Preview Environments for Pull Requests, and builds on Matrix Builds for Multi-Site Monorepos.
Prerequisites
- A pnpm (or npm/yarn) workspace with one package per site and per shared library.
- A task runner that understands the workspace graph — Turborepo here — or pnpm's
--filterwith...[origin/main]syntax. - A preview host that supports several projects or preview hostnames (Cloudflare Workers, Netlify, Vercel).
The Repository
apps/
docs/ (Astro, 1,300 pages)
marketing/ (Astro, 250 pages)
blog/ (Eleventy, 400 posts)
design/ (Astro + Storybook, 180 pages)
packages/
ui/ (shared components: used by docs, marketing, design)
tokens/ (design tokens: used by all four)
markdown/ (remark config: used by docs, blog)
Before this work, every pull request built and deployed all four sites: 14 minutes of CI, four preview links from four bots, and reviewers guessing which one mattered.
Step 1: Find Affected Sites
Turborepo computes affected packages from Git changes and the workspace graph:
npx turbo run build --filter='...[origin/main...HEAD]' --dry-run=json \
| jq -r '.tasks[] | select(.package | startswith("@site/")) | .package' | sort -u
...[origin/main...HEAD] means "packages changed in this range, plus everything that depends on them". A blog post edit yields @site/blog; a change to packages/markdown yields @site/docs and @site/blog; a change to packages/tokens yields all four. The output becomes the matrix for the build job.
Step 2: Build and Deploy Only Those
A detection job outputs the affected sites as JSON; the build job fans out over them:
jobs:
affected:
runs-on: ubuntu-latest
outputs: { sites: '${{ steps.a.outputs.sites }}' }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: a
run: echo "sites=$(npx turbo run build --filter='...[origin/main...HEAD]' --dry-run=json | jq -c '[.tasks[] | select(.package|startswith("@site/")) | .package | sub("@site/";"")] | unique')" >> "$GITHUB_OUTPUT"
preview:
needs: affected
if: needs.affected.outputs.sites != '[]'
strategy:
fail-fast: false
matrix: { site: '${{ fromJSON(needs.affected.outputs.sites) }}' }
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx turbo run build --filter=@site/${{ matrix.site }}
- run: npx wrangler versions upload --name ${{ matrix.site }}-site --tag pr-${{ github.event.number }} --cwd apps/${{ matrix.site }}
- run: echo "${{ matrix.site }}=$(cat apps/${{ matrix.site }}/.preview-url)" >> previews.txt
- uses: actions/upload-artifact@v4
with: { name: 'preview-${{ matrix.site }}', path: previews.txt }
fail-fast: false keeps one site's failure from cancelling the others' previews. Turborepo's remote cache means shared packages are built once and reused across matrix jobs, as described in Remote Caching with Turborepo for SSG Monorepos.
Step 3: Predictable Preview URLs
Each site has its own Worker (or Pages project), and each preview gets a hostname that combines the pull request number and site: pr-482.docs.preview.example.com, pr-482.blog.preview.example.com. A wildcard DNS record and a small routing Worker map those hostnames to the right preview version. Predictable URLs let links between sites work on previews too — the marketing site's "Read the docs" link is rewritten at build time to the docs preview for the same pull request when that site is also being previewed, and to production docs otherwise.
Step 4: One Combined Comment
Each matrix job uploads its preview URL; a final job collects them and updates a single sticky comment, using the same approach as Commenting on Preview Deploys:
### Previews for 3f9c1a2
| Site | Preview | Changed pages | Checks |
|---|---|---|---|
| docs | https://pr-482.docs.preview.example.com | 212 (template: ui/Callout) | ✅ |
| marketing | https://pr-482.marketing.preview.example.com | 38 | ✅ |
| design | https://pr-482.design.preview.example.com | 11 | ⚠️ contrast on Callout (dark) |
Not rebuilt: **blog** (not affected by this change)
The "Not rebuilt" line matters: reviewers otherwise wonder whether a missing preview is a failure.
Keeping Shared-Package Pull Requests Affordable
A change to packages/tokens rebuilds all four sites, which is the point — the preview shows the new tokens in every context — but it is also the most expensive kind of pull request. Three measures kept it reasonable: remote caching so unchanged packages are not rebuilt; limiting the matrix to three parallel jobs with max-parallel so shared-package pull requests do not monopolise runners; and a label, preview:minimal, that restricts previews to the design site for work-in-progress token changes, with the full set built only when the label is removed. On this repository, tokens pull requests were 6% of pull requests but 21% of preview CI minutes before these changes, and 11% after.
When the Graph Is Wrong
Affected detection is only as good as the dependency graph it reads, and monorepos accumulate hidden dependencies. On this repository, three surfaced in the first month. The docs site imported a JSON file from apps/marketing/data/pricing.json by relative path to show plan names, so a pricing change affected docs without the graph knowing; the fix was moving the file into a shared package. The blog's Eleventy config read the root .browserslistrc, so changing browser targets affected the blog, but the file was not declared as an input; Turborepo's globalDependencies setting now lists it. And a Tailwind preset in packages/ui scanned apps/*/src for class names, creating a reverse dependency that no package manifest described. Each of these showed up as a production bug in a site that had not been previewed. A weekly job that builds every site from main and compares the output with the last deployed version now catches any such drift, and each miss becomes a declared dependency.
Clean-up follows the same graph in reverse: when a pull request closes, the workflow deletes every preview it created, for every site it built, using the list recorded in the comment, so no site's previews are orphaned.
Measured Impact
| Measure | Build and preview all sites | Affected sites only |
|---|---|---|
| Median sites previewed per pull request | 4 | 1 |
| Median CI time per pull request | 14 min | 4 min 10 s |
| Preview CI minutes per month | 9,700 | 3,100 |
| Bot comments per pull request | 4 per push | 1 total |
| Reviewers opening the relevant preview | 52% | 86% |
Pitfalls & Rollback
- Missing root-level dependencies. Changes to the lockfile, root config or CI files affect everything; treat them as affecting all sites.
- Shallow clones. Affected detection needs the merge base; fetch full history or enough depth.
- Cross-site links pointing at production. Rewrite them to sibling previews when both are built.
- Silent skips. Say in the comment which sites were not rebuilt.
- Rollback: replace the affected matrix with the full site list; previews go back to building everything.
Conclusion
In a monorepo, previews should follow the dependency graph. Detect affected sites from changed files and the workspace graph, build and deploy only those with predictable per-site URLs, link sibling previews to each other, and post one comment that lists what was built and what was not. On this repository that cut preview CI time by two thirds and made the relevant preview the one reviewers actually opened.
FAQ
How do I know which sites a pull request affects in a monorepo?
Compute the dependency graph of the workspace and map changed files to packages. A site is affected if its own files changed or any package it depends on changed. Tools such as Turborepo, Nx and pnpm filters can list affected packages from a Git range.
Should every site get a preview on every pull request?
No. Previewing unaffected sites wastes build time and clutters the pull request. Build and preview only affected sites, and state clearly in the comment which sites were skipped and why.
How do preview URLs work for several sites?
Each site gets its own preview, usually on its own host project, with a URL that includes the pull request number, such as pr-482.docs.preview.example.com and pr-482.blog.preview.example.com. A single pull request comment lists them all.
What about a change to a shared component library?
It affects every site that depends on it, which can be all of them. That is correct and useful, since the previews show the change in every context, but it is worth capping parallelism and caching builds so such pull requests remain affordable.
Related
- Parent: Preview Environments for Pull Requests — previews in general.
- Matrix Builds for Multi-Site Monorepos — the build side of the monorepo.
- Remote Caching with Turborepo for SSG Monorepos — reusing shared package builds.
- Commenting on Preview Deploys — the single-site comment.
- Reusable Workflows for SSG Builds — the multi-repository alternative.
Filed underHow-tos