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 --filter with ...[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.

Affected site detection from changed files and the dependency graph The root asks what the pull request changed. A post in apps/blog affects only the blog. A change to packages/markdown affects docs and blog, which depend on it. A change to packages/ui affects docs, marketing and design. A change to packages/tokens affects all four sites. Root configuration changes such as the lockfile are treated as affecting all sites. Which sites does this pull request preview? What changed in this pull request? apps/blog/** Blog only 1 preview packages/markdown Docs + blog 2 previews packages/ui Docs, marketing, design 3 previews packages/tokens or lockfile All four sites 4 previews
Computed by Turborepo from the workspace graph; the matrix only includes affected sites.

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.

Preview hostnames for a pull request affecting three sites A pull request that changes the shared ui package produces three previews. The docs preview at pr-482.docs.preview.example.com, the marketing preview at pr-482.marketing.preview.example.com and the design preview at pr-482.design.preview.example.com. The blog is unaffected, so links to it from the previews point at production. One pull request, several previews pr-482.docs.preview… docs preview affected by packages/ui pr-482.marketing.preview… marketing preview affected by packages/ui pr-482.design.preview… design preview affected by packages/ui blog.example.com production unaffected; links go here
Cross-site links resolve to sibling previews when they exist, and to production 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

MeasureBuild and preview all sitesAffected sites only
Median sites previewed per pull request41
Median CI time per pull request14 min4 min 10 s
Preview CI minutes per month9,7003,100
Bot comments per pull request4 per push1 total
Reviewers opening the relevant preview52%86%
Monthly preview CI minutes before and after building only affected sites Vertical bars. Building and previewing all four sites on every pull request used 9,700 CI minutes a month. Building only affected sites used 3,100 minutes a month, with remote caching and capped parallelism for shared-package changes. Preview CI minutes per month 9,700 min All sites every PR 3,100 min Affected sites only
Three-month averages, about 420 pull requests per month.

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.

Filed underHow-tos