Monorepo Setup Guide
Step-by-step guide to building a production-grade Node.js monorepo for TypeScript packages with full automation.
Table of Contents
- Prerequisites
- Initialize the repository
- pnpm workspaces
- TypeScript
- Biome — linting & formatting
- Git hooks — Husky + commitlint
- Package build — tsdown
- Testing — Vitest
- Compatibility checks — publint + attw
- API documentation — TypeDoc
- Documentation site — VitePress
- Release automation — semantic-release
- CI/CD — GitHub workflows
- Adding a new package
1. Prerequisites
Before starting, ensure the following tools are available on your machine:
- Node.js ≥ 22 (LTS recommended)
- pnpm ≥ 10 — install via Corepack:
corepack enable && corepack prepare pnpm@latest --activate - Git ≥ 2.x
Why Corepack? Corepack ships with Node.js and manages package manager versions without requiring a global install. It also enforces the
packageManagerfield inpackage.json, which is how we prevent contributors from accidentally using npm or yarn.
2. Initialize the repository
git init
git branch -M main
git checkout -b developBranching strategy
This repo uses a two-branch model:
| Branch | Purpose |
|---|---|
main | Stable, releasable state. Protected. Never commit directly. |
develop | Active development. All day-to-day work goes here. |
The release flow is:
- Work accumulates on
developvia feature branches or direct commits. - When a release is ready, open a PR from
develop→main. - Merging that PR triggers semantic-release on
main(see step 12), which bumps versions, generates CHANGELOGs, and publishes packages automatically.
This model keeps
mainalways in a releasable state and gives a clear integration point for release decisions, without the overhead of a full Gitflow (norelease/*branches needed — semantic-release handles versioning).
Create the root package.json:
{
"name": "monorepo",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=22",
"pnpm": ">=10"
},
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"lint": "biome check .",
"format": "biome format --write .",
"docs:api": "typedoc",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "pnpm docs:api && pnpm docs:dev",
"prepare": "husky"
}
}Create .npmrc at the root to enforce strict engine checks and prevent accidental npm usage:
engine-strict=true
public-hoist-pattern[]=@myorg/*
engine-strict=truecauses pnpm (and npm) to hard-fail if the running Node/pnpm version does not satisfyengines. Combined withpackageManagerand Corepack, this is the strongest available guardrail against wrong-toolchain installs.
public-hoist-patterntells pnpm to hoist matching workspace packages into the rootnode_modules, making them resolvable from the repo root. This is required for TypeDoc'spackagesstrategy: TypeDoc runs from the root and needs to resolve inter-package dependencies (e.g.@myorg/core) when generating docs. Without this, TypeDoc fails with "cannot find module" errors. Replace@myorg/*with your own package scope.
Create a .gitignore:
node_modules/
dist/
.cache/
*.tsbuildinfo
coverage/
**/.vitepress/dist/
**/.vitepress/cache/
docs/api
docs/apiis generated by TypeDoc (step 10) and must not be committed. The CI docs workflow regenerates it on every deploy.
3. pnpm workspaces
Create pnpm-workspace.yaml at the root:
packages:
- "packages/*"All packages live under packages/. Each sub-directory is an independent publishable package.
Why a flat
packages/*layout vs nested scopes? A flat layout keepspnpm -rfilters simple and avoids glob ambiguity in workspace tooling. If you need logical grouping, use package name scopes (e.g.@myorg/ui,@myorg/utils) rather than nested directories.
Create the packages/ directory and a .gitkeep to track it:
mkdir packages && touch packages/.gitkeepVerifying workspace linking
When a package A depends on package B in the same monorepo, declare it as a workspace dependency:
{
"dependencies": {
"@myorg/utils": "workspace:*"
}
}pnpm install will symlink B into A's node_modules automatically. The workspace:* protocol resolves to the exact local version at publish time.
4. TypeScript
Install TypeScript and Node.js type definitions at the root (used by all packages):
pnpm add -Dw typescript @types/nodeCreate a root tsconfig.base.json that all packages extend:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"lib": ["ES2022"],
"types": ["node"],
"composite": true
}
}Create a root tsconfig.json that references all packages (for editor support and project-wide type checking):
{
"files": [],
"references": [
{ "path": "./packages/your-package" }
]
}
@types/nodeis required becausetsconfig.base.jsondeclares"types": ["node"]. Without it, TypeScript cannot find the Node.js type definitions, causingTS2688errors and unresolved globals likeconsole. Tools that invoketscdirectly — such as TypeDoc — will fail even if your bundler (tsdown) works fine on its own.
verbatimModuleSyntaxenforces thatimport typeis used for type-only imports. This is essential for bundlers (like tsdown) that strip types without runningtsc— it prevents runtime errors from type-only imports being emitted as realrequire()calls.
module: "NodeNext"withmoduleResolution: "NodeNext"is the correct setting for packages targeting Node.js. It requires explicit.jsextensions on relative imports (e.g.import { foo } from './foo.js') even in TypeScript source files, which aligns with ESM semantics.
5. Biome — linting & formatting
Biome replaces both ESLint and Prettier in a single, fast Rust-based tool.
pnpm add -Dw @biomejs/biome @side-xp/biome-configbiome.json at the root extends the shared config:
{
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"extends": ["@side-xp/biome-config/biome.json"]
}The shared config (@side-xp/biome-config) enforces:
- 2-space indentation, 120-character line width, LF line endings
- Single quotes, no semicolons, trailing commas everywhere
recommendedlint rules- Import organization via Biome's
assistfeature (Biome 2.x)
Override any rule locally by adding it to your biome.json alongside the extends — local keys merge on top of the extended config.
Add to package.json scripts (already listed in step 2):
pnpm lint # check
pnpm format # auto-fix formattingWhy Biome over ESLint + Prettier? The two-tool combo has well-known friction: format-on-save fights with lint-on-save, plugin version conflicts, and slow cold starts on large repos. Biome is 10–100× faster and has a single config file. The tradeoff is a smaller plugin ecosystem — but for a greenfield repo, the recommended ruleset covers the vast majority of real issues.
Why a shared config package? Centralizing Biome rules in
@side-xp/biome-configmeans all projects in the org stay consistent without copy-pasting config. When rules need to change, a single package update propagates everywhere.
6. Git hooks — Husky + commitlint
Husky
pnpm add -Dw husky
pnpm exec husky initThis creates .husky/ with a sample pre-commit hook.
Lint-staged (pre-commit)
Run Biome only on staged files to keep pre-commit fast:
pnpm add -Dw lint-staged.husky/pre-commit:
pnpm exec lint-staged.lintstagedrc.json:
{
"**/*.{ts,tsx,js,jsx,json,jsonc}": ["biome check --write"]
}commitlint (pre-push or commit-msg)
pnpm add -Dw @commitlint/cli @commitlint/config-conventional.commitlintrc.json:
{ "extends": ["@commitlint/config-conventional"] }.husky/commit-msg:
pnpm exec commitlint --edit "$1"Conventional commits format:
type(scope): description— e.g.feat(utils): add debounce helper. Types:feat,fix,docs,chore,refactor,perf,test,ci,build,revert. This format is the input contract for semantic-release's automatic versioning in step 12.
Why commitlint on commit-msg rather than pre-push? Catching a bad commit message immediately (before it's in history) is far less disruptive than rejecting a push with multiple already-made commits. Fix it once, not five times.
Git GUI clients (Fork, Sourcetree, Tower, etc.)
Git GUIs don't inherit your shell's PATH, so pnpm and node are not found at runtime.
Husky v9 provides ~/.config/husky/init.sh — sourced by the runner before every hook — to fix this. Create it once per machine:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"This is a one-time global setup per machine, not per project. Some GUIs (eg. Fork) also expose a dedicated "Git environment PATH" setting that can be used as an alternative.
7. Package build — tsdown
tsdown is a zero-config TypeScript package bundler built on Rolldown (the Rust-based Rollup successor). It produces both CJS and ESM outputs with proper .d.ts declaration files.
pnpm add -Dw tsdownEach package gets its own tsdown.config.ts:
import { defineConfig } from 'tsdown'
export default defineConfig([
{
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
outExtensions: () => ({ js: '.js', dts: '.d.ts' }),
},
{
entry: ['src/index.ts'],
format: ['cjs'],
dts: true,
outExtensions: () => ({ js: '.cjs', dts: '.d.cts' }),
},
])Why split into two config entries? When building dual ESM+CJS in a single pass, tsdown defaults to
.mjs/.cjsextensions to disambiguate. Splitting the configs and usingoutExtensionsgives conventional.jsfor ESM (correct when"type": "module"is set) and.cjsfor CommonJS, which matches theexportsfield consumers expect.
Each package's package.json should expose the built output correctly:
{
"name": "@myorg/my-package",
"version": "0.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": ["dist"],
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch"
}
}Why dual CJS+ESM output? The Node.js ecosystem is still in transition. Many tools (Jest, older bundlers, some CLIs) require CJS. Publishing both formats ensures maximum compatibility without forcing consumers to change their setup.
tsdown vs tsup: tsup (esbuild-based) is more battle-tested. tsdown is newer and Rolldown is still pre-1.0, so expect occasional rough edges. The upside is that tsdown will track Vite's bundler evolution and is likely the long-term standard. Either is a valid choice — just be aware of the maturity gap.
8. Testing — Vitest
pnpm add -Dw vitest @vitest/coverage-v8Root vitest.workspace.ts (workspace mode):
export default ['packages/*/vitest.config.ts']Vitest 4.x removed
defineWorkspacefromvitest/config. The workspace is now declared in a dedicatedvitest.workspace.tsfile with a plain array export — no import needed.
Each package's vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary'],
include: ['src/**'],
exclude: ['src/**/*.test.ts'],
},
},
});The test and test:coverage scripts are already wired up in the root package.json from step 2 (pnpm -r test / pnpm -r test:coverage). Each package exposes its own test and test:coverage scripts that Vitest runs.
json-summaryreporter is required fordavelosert/vitest-coverage-report-action(see step 13) to post a coverage summary comment on PRs.textis kept for readable local output. No third-party service needed — the action usesGITHUB_TOKENdirectly.
Why not Jest? Vitest shares Vite's plugin ecosystem, has first-class TypeScript support without a separate transform step, and is significantly faster due to parallel execution and esbuild-based transforms. For a TypeScript-first monorepo, Vitest is the clear choice in 2025+.
globals: falseis intentional. Relying on implicit globals (describe,it,expect) makes test files ambiguous outside the test runner. Explicit imports (import { describe, it, expect } from 'vitest') are better for IDE support and less surprising.
9. Compatibility checks — publint + attw
These two tools catch package distribution mistakes before publish.
pnpm add -Dw publint @arethetypeswrong/cliAdd to each package's package.json:
{
"scripts": {
"check:publint": "publint",
"check:attw": "attw --pack ."
}
}Run both in CI before publishing:
pnpm -r check:publint
pnpm -r check:attwWhat each tool checks:
- publint — validates that
exports,main,module, andtypesfields actually point to existing files, checks for common mistakes like missing CJS/ESM conditions. - attw (Are the Types Wrong?) — verifies that TypeScript consumers using different module resolution strategies (
bundler,node16,nodenext) all get correct types. Catches issues that publint misses.
Running both is not redundant — they catch different classes of errors. Think of publint as runtime-distribution checks and attw as type-distribution checks.
10. API documentation — TypeDoc
TypeDoc generates API reference docs directly from TypeScript source and JSDoc comments.
pnpm add -Dw typedoc typedoc-plugin-markdown typedoc-vitepress-themetypedoc.json at the root:
{
"$schema": "https://typedoc.org/schema.json",
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
"docsRoot": "./docs",
"entryPointStrategy": "packages",
"entryPoints": ["packages/*"],
"out": "docs/api",
"readme": "none",
"githubPages": false,
"entryFileName": "index",
"hidePageHeader": true,
"hideBreadcrumbs": true,
"skipErrorChecking": true
}Each package's package.json must declare its own TypeDoc entry point so TypeDoc's packages strategy can discover it:
{
"typedocOptions": {
"entryPoints": ["src/index.ts"],
"tsconfig": "./tsconfig.json"
}
}
entryPointStrategy: "packages"tells TypeDoc to discover each package'spackage.jsonand use itsmain/typesentry. This is the correct mode for monorepos — avoidentryPointStrategy: "expand"which can produce confusing cross-package output.
typedoc-plugin-markdownoutputs.mdfiles instead of standalone HTML, which VitePress (step 11) can consume directly. This avoids maintaining two separate documentation systems.
typedoc-vitepress-themeis a companion plugin that generates atypedoc-sidebar.jsonfile alongside the markdown output. VitePress imports this file to build the API sidebar automatically — no manual sidebar maintenance needed (see step 11).
docsRoottellstypedoc-vitepress-themewhere the VitePress root is. It uses this path to compute correct sidebar link prefixes. Set it to the directory containing your.vitepress/folder.
11. Documentation site — VitePress
VitePress hosts the project documentation and auto-generated API docs.
pnpm add -Dw vitepress viteWhy install
viteexplicitly? VitePress bundles its own Vite internally, but Vitest 4.x requires a newer Vite than VitePress's internal version. Without an explicit root-levelviteentry, pnpm may resolve Vitest's peer dependency against VitePress's older internal Vite, which breaks Vitest at startup. Addingviteat the root pins the version used by Vitest while VitePress continues to use its own copy.
Create the docs structure manually (skip vitepress init — it is interactive and adds scripts you already have):
Recommended structure:
docs/
.vitepress/
config.ts
api/ ← TypeDoc output (gitignored, generated)
guide/
index.md
index.md ← homepage
packages/
my-package/
docs/
index.md ← package-level docs (committed, lives next to source)
src/Package-level documentation can live inside each package alongside its source code. VitePress serves them via srcDir and rewrites:
// docs/.vitepress/config.ts
import { type DefaultTheme, defineConfig } from 'vitepress'
import typedocSidebar from '../api/typedoc-sidebar.json'
export default defineConfig({
base: '/my-repo/', // must match the GitHub repository name
title: 'My Monorepo',
description: 'Package documentation',
// Serve markdown from the repo root so package docs live next to their code.
srcDir: '..',
srcExclude: [
'**/node_modules/**',
'**/dist/**',
'**/coverage/**',
'*.md', // exclude root-level README.md etc.
],
// Map source paths (relative to srcDir) to clean URL paths.
rewrites: {
'docs/:path(.*)': ':path',
'packages/my-package/docs/:page': 'my-package/:page',
},
themeConfig: {
nav: [
{ text: 'Guide', link: '/guide/' },
{ text: 'Packages', items: [
{ text: 'my-package', link: '/my-package/' },
]},
{ text: 'API Reference', link: '/api/' },
],
sidebar: {
'/guide/': [{ text: 'Introduction', link: '/guide/' }],
'/my-package/': [{ text: '@myorg/my-package', items: [
{ text: 'Overview', link: '/my-package/' },
]}],
'/api/': typedocSidebar, // auto-generated by typedoc-vitepress-theme
},
},
})
srcDir: '..'sets the VitePress source root to the repo root instead ofdocs/. This lets package docs live insidepackages/*/docs/and be served by VitePress without copying files. Therewritesmap those paths to clean URLs.
typedocSidebaris imported directly from the file generated bytypedoc-vitepress-theme. The sidebar stays in sync with the API automatically — add or remove an export, rerunpnpm docs:api, and the sidebar updates with no config changes needed.
Generate API docs before building or previewing the site:
pnpm docs:api && pnpm docs:build.
12. Release automation — semantic-release
semantic-release automates version bumps and CHANGELOG generation based on conventional commit messages.
pnpm add -Dw semantic-release multi-semantic-release \
@semantic-release/changelog \
@semantic-release/exec \
@semantic-release/git \
@semantic-release/github
multi-semantic-releaseruns from the repo root and handles all packages in topological order — packages are released after their local dependencies. This matters whenpkg-bdepends onpkg-a: if both changed,pkg-amust be published first sopkg-bcan resolve the new version. The alternative (semantic-release-monorepo) runs per-package and doesn't handle this ordering.
@semantic-release/npmis bundled withsemantic-releaseitself — no separate install needed. Same for@semantic-release/commit-analyzerand@semantic-release/release-notes-generator.
Root .releaserc.json:
{
"branches": ["main"],
"tagFormat": "${name}@${version}",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
["@semantic-release/npm", { "npmPublish": false }],
["@semantic-release/exec", { "prepareCmd": "pnpm install --no-frozen-lockfile" }],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json", "pnpm-lock.yaml"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
["@semantic-release/exec", {
"publishCmd": "pnpm publish --no-git-checks --access public"
}],
"@semantic-release/github"
]
}
tagFormatscopes release tags to the package name:@myorg/my-package@1.2.0instead of a flatv1.2.0. This is essential in a monorepo where multiple packages are versioned independently — without it, all packages would share a single release tag namespace.
npmPublish: falsedisables@semantic-release/npm's publish step while keeping its version-bump logic. Publishing is delegated to@semantic-release/execusingpnpm publish --no-git-checks --access public.
Lockfile drift:
multi-semantic-releaserewritesworkspace:*to the concrete resolved version (e.g.1.0.0) in dependent packages'package.jsonas part of the release. This leaves the lockfile out of sync withpackage.json, causingpnpm install --frozen-lockfileto fail on the next CI run. TheprepareCmdstep regenerates the lockfile after the version bump (within thepreparelifecycle phase, before@semantic-release/gitcommits), andpnpm-lock.yamlis added to the git assets so it lands in the same release commit — no extra commit needed.
--deps.bump=ignoreonmulti-semantic-releaseprevents a dependent package from being released solely because one of its workspace dependencies was released. Without it, releasingpkg-awould trigger a patch bump of every package that depends on it, even with no new commits. This flag does not prevent theworkspace:*→ concrete version rewrite inpackage.json— that still happens and is handled by the lockfile regeneration above.
CHANGELOG.md,package.json, andpnpm-lock.yamlin@semantic-release/gitassets are relative to each package root.multi-semantic-releaseruns semantic-release in the context of each package, so paths resolve correctly.
Each package that should be published to npm must set "private": false. Packages with "private": true are skipped by @semantic-release/npm automatically — useful for internal or not-yet-published packages.
Commit → version mapping:
| Commit type | Version bump |
|---|---|
fix: | patch (0.0.x) |
feat: | minor (0.x.0) |
feat!: or BREAKING CHANGE: | major (x.0.0) |
chore:, docs:, test: | no release |
semantic-release vs changesets: changesets is an alternative that gives developers manual control over which packages get bumped (via
changesetfiles checked into git). It's preferred when you want human curation over every release. semantic-release is fully automatic — better for teams that trust their commit discipline and want zero-friction releases. Given the conventional commit enforcement already in place (step 6), semantic-release is a natural fit here.
13. CI/CD — GitHub workflows
Each stage lives in its own reusable workflow file (prefixed with _). The orchestrator ci.yml wires them into a pipeline — readable at a glance, each stage independently maintainable.
.github/workflows/
ci.yml ← orchestrator
_check.yml ← lint, build, test, compat checks
_release.yml ← semantic-release
_docs.yml ← VitePress build + GitHub Pages deployci.yml — the orchestrator:
name: CI
on:
push:
branches: [main, develop]
pull_request:
permissions:
contents: write
issues: write
pull-requests: write
packages: write
pages: write
id-token: write
jobs:
check:
uses: ./.github/workflows/_check.yml
release:
needs: check
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: ./.github/workflows/_release.yml
secrets: inherit
docs:
needs: release
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: ./.github/workflows/_docs.yml
secrets: inherit_check.yml — runs on every trigger, posts coverage report on PRs:
name: Check
on:
workflow_call:
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build
- run: pnpm test:coverage
- uses: davelosert/vitest-coverage-report-action@v2
with:
name: my-package
working-directory: packages/my-package
- run: pnpm -r check:publint
- run: pnpm -r check:attw_release.yml — only runs on push to main, after check passes:
name: Release
on:
workflow_call:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
packages: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm exec multi-semantic-release --deps.bump=ignore
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}_docs.yml — only runs after release succeeds:
name: Deploy Docs
on:
workflow_call:
jobs:
docs:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm docs:api
- run: pnpm docs:build
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: docs/.vitepress/dist
- uses: actions/deploy-pages@v5
id: deploymentTop-level
permissionsin the caller defines the maximum grant available to all called reusable workflows. GitHub doesn't allow settingpermissionson individual jobs that call reusable workflows, so the ceiling must be set here. Each called workflow still declares its own narrower permissions — the union just has to fit within this ceiling.
needs+ifonreleaseanddocsmeans they only run on push tomain, and each only after the previous job succeeds. On PRs anddeveloppushes, onlycheckruns.
secrets: inheritpasses all caller secrets to the called workflow — required forGITHUB_TOKENto be available inside_release.ymland_docs.yml.
fetch-depth: 0is required on thereleasejob — semantic-release walks commit history to determine what changed since the last release tag. A shallow clone (the default) will cause it to miss commits or fail entirely.
NODE_AUTH_TOKENis set toGITHUB_TOKEN(not a separate secret) becauseactions/setup-nodeuses it to write the npm auth token into.npmrcfor the configuredregistry-url. No extra secrets needed.
Publishing to npmjs.com instead: replace
registry-urlwithhttps://registry.npmjs.org, setNODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}, and remove"registry"from each package'spublishConfig.
14. Adding a new package
This is the routine checklist for adding a package to the monorepo once the above is in place.
Scaffold — create
packages/my-package/with:package.json(see step 7 template) — includetypedocOptionspointing tosrc/index.tstsconfig.jsonextending root basetsdown.config.tsvitest.config.tssrc/index.ts
Register in root
tsconfig.json— add{ "path": "./packages/my-package" }toreferences.Install dependencies — run
pnpm installfrom the root. Cross-package deps useworkspace:*.Add package documentation — create
packages/my-package/docs/index.mdwith installation and usage instructions.Register in VitePress — in
docs/.vitepress/config.ts, add:- A
rewritesentry:'packages/my-package/docs/:page': 'my-package/:page' - A nav item and sidebar entry under
/my-package/
- A
Add coverage report step — add a
davelosert/vitest-coverage-report-action@v2step toci.ymlwith the new package'snameandworking-directory.Verify — run
pnpm build && pnpm test && pnpm -r check:publint && pnpm -r check:attw && pnpm docs:api && pnpm docs:build.Publish config — add
"publishConfig": { "registry": "https://npm.pkg.github.com", "access": "public" }to the package'spackage.jsonto publish to GitHub Packages. Omit"private": true(or remove it entirely).
The API reference sidebar updates automatically after step 6's
docs:api—typedoc-vitepress-themeregeneratestypedoc-sidebar.jsonfrom the new package's exports. Only the hand-written nav/sidebar entries in step 5 need manual attention.
Summary of tool choices
| Concern | Tool | Key reason |
|---|---|---|
| Package manager | pnpm | Best-in-class workspace support, strict by default |
| Language | TypeScript | Required for type-safe packages |
| Lint + format | Biome | Single tool, 10–100× faster than ESLint + Prettier |
| Git hooks | Husky | Widely supported, integrates with lint-staged |
| Commit convention | commitlint | Input contract for semantic-release |
| Build | tsdown | Modern Rolldown-based bundler, zero config |
| Tests | Vitest | TypeScript-native, fast, workspace-aware |
| Compat checks | publint + attw | Complementary — catch runtime vs type dist errors |
| API docs | TypeDoc | TypeScript-first, markdown output for VitePress |
| Docs site | VitePress | Vite-native, fast, integrates with TypeDoc output |
| Releases | semantic-release | Fully automated versioning from commit history |
| CI | GitHub Actions | Native GitHub integration |