Splitting a Next.js app into a monorepo without breaking prod
At some point most successful single-codebase projects need to grow. A second app appears — usually a native client, an admin console, or a marketing site that's outgrown being a route in the main app. The shape that solves this with the least new infrastructure is a pnpm workspace.
We did this last week for an internal project — promoted a single Next.js repo to apps/api + packages/shared + a future apps/native. The whole refactor took under an hour and didn't break a live Stripe webhook. Here's what worked.
Decisions to make first
Three calls before touching code:
1. Package manager. If you're starting fresh, pnpm. It handles workspaces with the least ceremony, the symlink layout is straightforward, and Vercel auto-detects it. 2. Tooling depth. Skip Turborepo and Nx for now. A clean pnpm workspace is enough for two to four apps. Bring in Turborepo when build caching starts to matter — usually past six apps. 3. Shared-package shape. Decide whether the shared package builds (TypeScript compiled to JS in dist/) or ships source. For internal-only consumption inside a workspace, ship source. Less infrastructure, no build step, and Next.js's transpilePackages handles the rest.
The order of operations
Doing it in the right order matters because each step verifies the previous one:
1. Hoist the workspace. Add a pnpm-workspace.yaml at the repo root, and a minimal root package.json with packageManager: pnpm@x.y.z. 2. Move the existing app into apps/api/ using git mv. Use git mv for tracked files and plain mv for ignored ones (Next's next-env.d.ts, .next/, .vercel/, etc.). git mv preserves blame, which matters more than people think. 3. Pull shared types and pure functions into packages/shared/src/. Anything framework-coupled (Next-only cookies, Supabase SSR helpers) stays in the app. 4. Update imports. sed works. Run a focused find-and-replace, then tsc --noEmit to confirm. 5. Verify the build. pnpm --filter api build should pass cleanly. If it doesn't, fix it before doing anything else. 6. Then, and only then, update Vercel's Root Directory to apps/api in the dashboard. Without this, the next push deploys nothing and your prod paywall breaks.
That last point is the one teams skip and regret. Make the dashboard change *before* the next push.
What stayed simple
packages/sharedexports./src/index.tsdirectly. No build step. Next.js'stranspilePackagesarray handles the on-the-fly compilation.tsconfig.jsonin each app extends a base config or, for very small monorepos, just stands alone. We didn't bother with a shared base.- No path aliases at the workspace level. The
@/alias inside each app stays scoped to that app.
What needs care
- Turbopack's workspace root. See the other post — pin it explicitly.
.gitignorepatterns. Patterns rooted with a leading slash (/node_modules,/.next/) won't match in subdirectories. De-anchor them.- Lockfile. Delete
package-lock.jsonif you're switching from npm. The firstpnpm installat the workspace root generatespnpm-lock.yamlcovering all packages. - Stripe / payment webhooks in production. Test the deploy chain end-to-end with a small commit before assuming everything is fine.
The payoff
Forty minutes of work, and what we got back:
- One
pnpm installfor the whole repo. - Shared types between the web app and the new native app, with no copy-paste.
- A cleaner mental model: this code is API-only, that code is native, this code is shared.
Future-self thanks present-self. Worth doing before the second app gets too big to be tidy.