A repo that was already a monorepo — just not in the package manager

kentcdodds.com had been running two deployable services in one git repo: the main site (React Router, SQLite, deployed to Fly) and an OAuth worker (Cloudflare Worker). Adding the Call Kent audio worker and container meant four separate deployables, each with its own package.json, lockfile, and tsconfig.json. The root package.json belonged to the site and treated the others as optional siblings.

The folder tree was already monorepo-shaped; the package manager just hadn't caught up. The migration's single structural rule fixed that: everything runnable now lives under services/*.

services/
  site/                       ← the main app
  oauth/                      ← Cloudflare OAuth worker
  call-kent-audio-worker/     ← Cloudflare audio worker
  call-kent-audio-container/  ← Docker audio container

The root package.json became a thin orchestration layer holding the workspace declaration, Nx configuration, and convenience scripts forwarding into the site workspace:

{
	"name": "kcd-workspace",
	"private": true,
	"workspaces": ["services/*"],
	"scripts": {
		"dev": "npm run dev --workspace kentcdodds.com",
		"build": "npm run build --workspace kentcdodds.com",
		"typecheck": "npm run typecheck --workspace kentcdodds.com",
		"typecheck:all": "nx run-many -t typecheck"
	},
	"devDependencies": {
		"nx": "^22.5.4"
	}
}

The actual app scripts stayed in services/site/package.json: ci:verify, test:browser, build, postinstall — all scoped to the thing that uses them. Three nested lockfiles (call-kent-audio-container/package-lock.json, call-kent-audio-worker/package-lock.json, oauth/package-lock.json) were replaced by a single root lockfile. The raw diff stat looked alarming — 726 files, 21,000 deletions — but most of it was three lockfiles evaporating.

Keeping Nx minimal

Nx was deliberately restrained: one nx.json at the root with caching defaults and package-script inference, no hand-written project.json files, no plugin configuration beyond what Nx infers. The structural change was the win; Nx mainly handled caching.

{
	"namedInputs": {
		"sharedGlobals": [
			"{workspaceRoot}/package-lock.json",
			"{workspaceRoot}/tsconfig.base.json",
			"{workspaceRoot}/nx.json"
		]
	},
	"targetDefaults": {
		"build": { "cache": true, "inputs": ["production", "^production"] },
		"lint": { "cache": true, "inputs": ["default", "^default"] },
		"typecheck": { "cache": true, "inputs": ["default", "^default"] },
		"test": { "cache": true, "inputs": ["default", "^default"] }
	}
}

What the services/* constraint exposed

Enforcing that every runnable thing has its own package under services/* surfaces assumptions code was making about its runtime location. Three categories of breakage turned up.

Import aliases stopped crossing package boundaries

The site's #other/* import alias, defined in the root package.json, pointed to ./other/* relative to the package root. From services/site, that directory is two levels up and outside the package, so Node rejected the imports:

ERR_INVALID_PACKAGE_TARGET: Package subpath '#other/semantic-search/...'
is not defined in "services/site/package.json"

The fix was mechanical: replace the aliases with explicit relative paths.

- } from '#other/semantic-search/ignore-list-patterns.ts'
+ } from '../../../../other/semantic-search/ignore-list-patterns.ts'

Production went down when content moved

The site fetches blog posts, talks, and testimonials from GitHub at runtime, with a hardcoded path prefix:

const mdxFileOrDirectory = `content/${relativeMdxFileOrDirectory}`

After the migration, content lived at services/site/content/, not content/. The GitHub API dutifully returned 404s and production went down. The fix centralized all content path logic in a new utility:

// services/site/app/utils/github-content-paths.server.ts
export const GITHUB_CONTENT_PATH = 'services/site/content'

export function getGitHubContentPath(relativePath: string): string {
	return `${GITHUB_CONTENT_PATH}/${relativePath}`
}

Used at every callsite:

- const mdxFileOrDirectory = `content/${relativeMdxFileOrDirectory}`
+ const mdxFileOrDirectory = getGitHubContentPath(relativeMdxFileOrDirectory)

The lesson: don't merge a 726-file structural refactor remotely without pulling it down and running it locally. Even that might not have caught it — the Cursor Cloud Agent's local GitHub API mock handled the path change fine, but the actual implementation didn't.
As a safety net, the site now returns graceful fallbacks with a message and a direct link to the GitHub repo when content can't be fetched, instead of crashing or rendering empty pages.

Docker build stages have their own dependency graph

Moving the site to services/site meant updating the Dockerfile to build from the new path. The production-deps stage copied services/site/package.json but not services/site/prisma/. Two stages need the Prisma schema: deps runs npm install, which triggers postinstall: prisma generate, and build runs npx prisma generate explicitly. The schema was missing where it was needed, fixed with two lines:

ADD services/site/package.json /app/services/site/package.json
+ ADD services/site/prisma /app/services/site/prisma
+ ADD services/site/prisma.config.ts /app/services/site/prisma.config.ts
  ADD services/oauth/package.json /app/services/oauth/package.json

This slipped through because Cursor Cloud Agent can't build Docker images. Asked to verify the build, it said it couldn't but was confident things would work — a lesson in hubris.

CI restructured around actual workloads

Before the migration, CI ran a workspace-wide install then executed everything — fine when there was effectively one package. With real boundaries, CI was optimized around usage frequency. The site changes much more often than the workers, so site CI now does a site-only install:

- name: 📥 Install site deps
  run: npm ci --workspace=kentcdodds.com

The worker pipelines mirror this: each installs only its own workspace when needed.

The other meaningful CI change: browser tests were always part of ci:verify, but Playwright browser binaries were never installed in the gate job. The old CI didn't include browser tests in the gate, so the assumption only surfaced once the migration restructured it:

browserType.launch: Executable doesn't exist

Fixed with a cached Playwright browser install before ci:verify:

- name: 🧰 Cache Playwright browsers
  id: playwright-cache
  uses: actions/cache@v5
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}

- name: 🌐 Install Playwright browsers
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: npm run test:e2e:install --workspace kentcdodds.com

Takeaways

Don't ask an agent how confident it is that nothing will break. Make it prove itself, or verify locally. For a personal site, half an hour of downtime is tolerable, so the tolerance was lax — but a production application with millions of users would warrant staging environments or preview deploys.

The real takeaway: the services are technically interdependent, but they don't share code or have hard dev dependencies on each other. Nx was useful mainly for caching. The structure was the actual win.