Hosting
Add documentation to a monorepo without breaking app builds
A docs workspace beside your app that shows live examples from a shared package, builds with Turborepo, and deploys on its own, skipping commits it isn't part of.
By Hayden Bleasel8 min read

To add documentation to a monorepo, give the docs their own workspace, like apps/docs, with a package.json that depends on blume. Your package manager installs it with everything else, Turborepo builds it as one more task with its own dist/ output, and it deploys as a separate project rooted at apps/docs. Your app's dependencies, build command, and deployments stay as they are.
By the end, you have a docs site beside your app that shows live examples from a shared component package, rebuilds only when the docs or that package change, and deploys to Vercel on its own. The walkthrough uses pnpm 12.6 workspaces and Turborepo 2.11, with an app in apps/web and a shared React package, @acme/ui.
Some setups need something else. To serve the docs under your app's own domain at /docs, do this first, then follow Serve a separate documentation site under /docs in Next.js. If you want docs pages to be routes inside your React app, sharing its layout and auth, a library that runs in the app fits better; see Blume vs Fumadocs. If the docs come from several repositories, see Build one documentation portal from multiple GitHub repositories.
The layout
Here's the repository at the end. Everything except apps/docs exists already:
acme/
├── apps/
│ ├── web/ your application
│ └── docs/ the new docs workspace
├── packages/
│ └── ui/ @acme/ui, shared React components
│ ├── src/button.tsx
│ └── examples/button.tsx
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.jsonPin pnpm in the root package.json. pnpm switches to that version when you run it in the repository, and pnpm's GitHub Action installs it in CI:
{
"name": "acme",
"private": true,
"packageManager": "pnpm@12.6.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
},
"devDependencies": {
"turbo": "2.11.4"
}
}Create the docs workspace
From the repository root, scaffold Blume into apps/docs:
pnpm dlx --allow-build=esbuild blume init apps/docs --yes --template docs --no-install--allow-build=esbuild lets pnpm 12 run esbuild's install script while it fetches the CLI, and --no-install waits until pnpm knows about the folder. You get a package.json named docs with dev, build, and doctor scripts, a docs/index.mdx home page, a blume.config.ts, and a .gitignore for node_modules/, .blume/, and dist/. .blume/ is the Astro project Blume generates and drives, so it never gets committed. pnpm, Turborepo, and Vercel all identify workspaces by name, so if another package is already called docs, rename this one.
Then name the site and tell Blume where it sits in the repository:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
description: "Guides and component docs for Acme.",
github: {
owner: "acme",
repo: "acme",
dir: "apps/docs",
},
});Edit links are built from the repository root, so dir makes the home page's link open apps/docs/docs/index.mdx instead of a docs/index.mdx that doesn't exist.
Register it with pnpm
pnpm installs only the folders pnpm-workspace.yaml lists, and pnpm 12 stops any install whose dependencies have build scripts nobody approved. Astro, which Blume runs on, depends on esbuild, which has one:
packages:
- apps/*
- packages/*
allowBuilds:
esbuild: trueapps/* already covers the new folder. If your file lists apps one by one, add apps/docs. Then install from the root and start the docs on their own:
pnpm install
pnpm --filter docs devThe site is at http://localhost:4321. The docs' dependencies, Astro among them, belong to the docs workspace. pnpm keeps them out of the root node_modules, so your app can't import them without declaring them.
Add the Turborepo task
You don't need to touch the root turbo.json. It probably describes your app's build, like this one for a Next.js app:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}Those outputs don't include the docs' dist/, so a cache hit would restore nothing. Give the docs a package configuration of their own that extends the root and replaces only the outputs:
{
"$schema": "https://turborepo.dev/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}The docs' build keeps the root's dependsOn, and dev comes from the root unchanged. Check what Turborepo will run:
pnpm turbo run build --dry-run --filter=docsdocs#build should show Command = blume build and Outputs = dist/**. Turborepo hashes the files in apps/docs that .gitignore doesn't exclude, so the generated .blume/ and dist/ never change the hash.
Show examples from a shared package
Blume's <Component> renders an example file as a live preview beside its source. The examples can live in the package they demonstrate, so whoever changes a component updates its example in the same pull request. First, declare that the docs depend on the package:
pnpm --filter docs add "@acme/ui@workspace:*"The docs don't import @acme/ui by name, but Turborepo and Vercel build their dependency graphs from package.json, and this entry is what tells them a change to packages/ui affects the docs. Here's the package, its component, and one example. An example file default-exports the component to preview:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./button": "./src/button.tsx"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"react": "^19.3.0"
}
}import type { ComponentProps } from "react";
export function Button(props: ComponentProps<"button">) {
return (
<button
className="rounded-md bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500"
{...props}
/>
);
}import { useState } from "react";
import { Button } from "../src/button";
export default function ButtonExample() {
const [count, setCount] = useState(0);
return <Button onClick={() => setCount(count + 1)}>Clicked {count} times</Button>;
}Point examples at that folder. The path is relative to apps/docs:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
description: "Guides and component docs for Acme.",
examples: {
source: "../../packages/ui/examples",
css: "examples.css",
},
github: {
owner: "acme",
repo: "acme",
dir: "apps/docs",
},
});Previews get Tailwind, scanned from the docs workspace and the examples folder. The button's classes live in packages/ui/src, which neither covers, so add it with an @source directive, written relative to the stylesheet:
/* Scan the shared UI package for the Tailwind classes its components use. */
@source "../../packages/ui/src";Now any page can show the example by its path, without the extension:
---
title: Button
description: The primary action button from @acme/ui, with a live example.
---
Import it from the shared UI package:
```tsx
import { Button } from "@acme/ui/button";
```
<Component path="button" />Keep React on one version across the workspace. With matching versions, pnpm links Blume's React and the one @acme/ui imports to the same copy, so the example's hooks run against the React that renders them. For more on writing examples, see Add live React component examples to Markdown documentation.
Deploy the docs as their own project
The docs deploy as a second Vercel project from the same repository, so the app's project and its settings stay untouched. Add a vercel.json to the docs workspace:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "turbo run build",
"outputDirectory": "dist"
}In Vercel, add a new project, import the repository, and set Root Directory to apps/docs. Set the Node.js version to 22 or later, since Blume needs 22.12 or newer, and deploy. Vercel detects pnpm from the lockfile and installs the workspace, and turbo run build run from apps/docs builds only the docs and what they depend on. Leave Include source files outside of the Root Directory in the Build Step on, which is the default: the build reads the examples in packages/ui.
Blume reads the site URL for the sitemap, canonical links, and Open Graph images from Vercel's environment. Turborepo's strict environment mode hides undeclared variables from tasks, but it always passes Vercel's VERCEL_* through, so there's nothing to declare. If you later switch to server rendering with vercel() from blume/deploy, the build writes .vercel/output, so add ".vercel/output/**" to the docs' outputs.
Build only what changed
Vercel skips deploying a monorepo project when a commit doesn't touch it, and new projects have this on by default (Skip deployment, under Root Directory). It needs a GitHub repository, every workspace listed in pnpm-workspace.yaml with a unique name, and dependencies between workspaces declared in package.json, which is why you added @acme/ui to the docs. Turborepo reads the same graph. Here's what turbo run build --affected selected against main for four branches of a repository with this layout:
| The branch changed | What builds |
|---|---|
apps/docs/docs/index.mdx | The docs |
apps/web/src/main.tsx | The app |
packages/ui/examples/button.tsx | The docs and the app |
The root turbo.json | Everything |
Vercel treats a change outside every workspace, like a root config file, as a global change and deploys every project. On a Git host other than GitHub, set the docs project's Ignored Build Step to:
turbo query affected --base=$VERCEL_GIT_PREVIOUS_SHA --packages docs --exit-codeIt exits 1 when the docs are affected, so the build runs, and 0 when they aren't, so Vercel skips it.
In CI, the same flag keeps a docs-only pull request from building the app:
name: CI
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --affectedIn GitHub Actions, Turborepo compares the pull request with its base branch. It needs that history, which is what fetch-depth: 0 is for: in a shallow clone, it treats every package as changed. To check the docs' links on the same pull requests, see Catch broken Markdown links in GitHub Actions.
Troubleshooting
pnpm install fails with ERR_PNPM_IGNORED_BUILDS
The error lists Ignored build scripts: esbuild@…. Add esbuild: true under allowBuilds in the root pnpm-workspace.yaml, not in the docs folder, and install again.
pnpm doctor doesn't run Blume's checks
pnpm has a built-in doctor command that wins over the script. Run pnpm --filter docs run doctor instead.
The build fails with BLUME_ENTRY_ID_MISMATCH
A second filesystem() source has a root of its own, so its pages would 404. Filesystem sources must share one root and split it with include globs. Move those pages under apps/docs/docs and remove the second source. See Content sources.
An include fails with BLUME_INCLUDE_OUTSIDE_ROOT
Includes can only splice files inside the content root. Show component code through examples instead, or move the snippet into apps/docs/docs/_snippets. See Includes.
The page says "No example found at button"
Blume found no file at that path under examples.source. Check that the source is relative to apps/docs, and that the path leaves off the extension.
The preview renders without styles
The component's classes weren't scanned. Check the @source path, which is relative to examples.css, and that css under examples names that file. Blume warns when the file it names doesn't exist.
Vercel can't find the build output after a cache hit
Turborepo restored the docs' task from cache, but the task's outputs didn't include dist/**. Check apps/docs/turbo.json and the dry run above.
The sitemap is missing on another host
Turborepo's strict mode passes Vercel's variables through but hides other hosts', so Blume can't detect the site URL. Add them to the docs' build task in apps/docs/turbo.json, like "env": ["NETLIFY", "URL"] on Netlify, or set deployment.site. A token a content source reads, like NOTION_TOKEN, needs the same treatment.
Next step
Scaffold the docs workspace
Run it from the repository root, then approve esbuild in pnpm-workspace.yaml, install, and give the docs their own turbo.json.
pnpm dlx --allow-build=esbuild blume init apps/docs --yes --template docs --no-installA step here not working for you? Report a broken step.