Lessons from Running 4 Production Next.js Apps in a Monorepo

For the past year, I've been running four production Next.js applications from a single monorepo: FreeAcademy (an LMS with 60+ courses), my portfolio site, Calisthenics Association (a fitness certification platform), and Cynked (a marketing/agency site).
This isn't a "monorepos are awesome" hype piece. It's a honest look at what actually happens when you commit to this architecture for real projects with real users.
The Setup
Here's what I'm working with:
platforms/
βββ apps/
β βββ freeacademy-ai/ # Port 3000 - LMS with Supabase, Stripe
β βββ portfolio/ # Port 3001 - Blog, courses, code playgrounds
β βββ calisthenics/ # Port 3002 - Certifications, exams, payments
β βββ cynked/ # Port 3004 - Marketing site, contact forms
βββ packages/
β βββ admin/ # Shared admin components
βββ turbo.json
βββ package.json
All four apps use Next.js 16 with App Router, TypeScript, and Tailwind CSS. Three use Supabase for auth and database. Two integrate Stripe for payments. All deploy to Vercel.
Lesson 1: Port Conflicts Will Haunt You
This sounds trivial until you've lost an hour debugging why your API calls are hitting the wrong app.
Each app needs a dedicated port. I use 3000, 3001, 3002, and 3004 (I skipped 3003 for reasons I no longer remember). The problem? Every Next.js app defaults to port 3000, and developers forget to specify the port flag.
My solution:
// apps/freeacademy-ai/package.json { "scripts": { "dev": "next dev --port 3000" } } // apps/portfolio/package.json { "scripts": { "dev": "next dev --port 3001" } }
I also added a root-level script to kill all dev ports when things get messy:
// package.json (root) { "scripts": { "kill-ports": "kill-port 3000 3001 3002 3004", "dev:clean": "npm run kill-ports && npm run dev" } }
npm run dev:clean has saved me countless times when orphaned processes hold onto ports after crashes.
Lesson 2: Turborepo Caching Is Magic (Until It Isn't)
Turborepo's caching is genuinely impressive. Run turbo build and watch it skip unchanged apps:
$ turbo build β’ Packages in scope: @platforms/admin, @platforms/calisthenics, @platforms/cynked, @platforms/freeacademy-ai, @platforms/portfolio β’ Running build in 5 packages @platforms/portfolio:build: cache hit, replaying logs @platforms/cynked:build: cache hit, replaying logs @platforms/calisthenics:build: cache bypass, running build @platforms/freeacademy-ai:build: cache hit, replaying logs
Three apps built instantly from cache. One actually ran. Beautiful.
But here's what the tutorials don't tell you: cache invalidation is your responsibility. If you change an environment variable, Turborepo doesn't know. If you update a shared package, you need proper dependsOn configuration.
My turbo.json:
{ "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] }, "test": { "dependsOn": ["^build"] }, "dev": { "cache": false, "persistent": true } } }
The key insight: dev should never be cached. I learned this after spending an afternoon wondering why my changes weren't appearing.
Lesson 3: Shared Packages Are Harder Than They Look
I started with grand ambitions: shared UI components, shared utilities, shared types. A year later, I have exactly one shared package: @platforms/admin.
Why? Because "shared" code creates coupling, and coupling creates coordination costs.
Every time I wanted to change a shared component, I had to check four apps. Every "quick improvement" became a compatibility exercise. The admin package works because it's genuinely identical across appsβsame dashboard, same CRUD operations, same styling.
What I'd do differently: Start with duplication. Extract only when you have three or more identical implementations.
The code that tempted me to share but shouldn't have been:
- Auth context (each app has slightly different user requirements)
- Layout components (each site has different navigation)
- API utilities (each app talks to different backends)
The code that genuinely belongs in a shared package:
- Admin panel components (identical across all apps)
- Email templates (same structure, different content)
- Database types (generated from the same Supabase instance)
Lesson 4: Testing Strategy Matters More Than Test Count
Each app has its own test suite. FreeAcademy has 769 tests. The others have... fewer. Here's what I learned about testing in a monorepo:
Run tests in parallel, not sequence:
// turbo.json { "tasks": { "test": { "dependsOn": ["^build"] } } }
Turborepo runs all test suites concurrently. A full test run takes 35 seconds instead of 3 minutes.
Pre-push hooks need to be fast:
I initially ran the full test suite on every push. This made me dread pushing code. Now I run linting on pre-push and full tests in CI:
# .husky/pre-push npm run lint
Watch mode doesn't scale:
Jest's watch mode with watchman works great for one project. With four projects and hundreds of test files, it consumes RAM like it's free. I ended up adding --no-watchman to all test scripts:
{ "scripts": { "test": "jest --no-watchman" } }
Lesson 5: Environment Variables Need a System
Four apps means four sets of environment variables. Some are shared (Supabase URL), some are app-specific (Stripe keys), some are environment-specific (production vs development).
My system:
apps/freeacademy-ai/.env.local # Development secrets
apps/freeacademy-ai/.env.example # Template for new developers
apps/portfolio/.env.local
apps/portfolio/.env.example
# ... etc
Each app has its own .env.local that's gitignored. The .env.example files document required variables without exposing secrets.
For Vercel, I use project-level environment variables. Each app is a separate Vercel project pointing to the same repo but different root directories.
The mistake I made early on: trying to use a root-level .env file. This breaks because Next.js only loads .env files from the app directory.
Lesson 6: Deployments Need Isolation
When I push to main, I don't want all four apps to rebuild. Vercel's "Ignored Build Step" feature is essential:
# vercel-ignore.sh (in each app) #!/bin/bash echo "Checking if build is needed..." git diff HEAD^ HEAD --quiet -- apps/freeacademy-ai/ packages/admin/
This script exits with code 0 (skip build) if no files changed in the app or shared packages.
But here's the catch: shared package changes should trigger all dependent app builds. My ignore script checks both the app directory and any packages it imports.
Lesson 7: IDE Performance Degrades
VS Code with four Next.js apps open is... a lot. TypeScript language server checks types across all packages. ESLint scans everything. The "Go to Definition" feature sometimes takes you to the wrong app's version of a file.
What helped:
-
Workspace settings per app: I have
.vscode/settings.jsonin each app with app-specific configurations. -
Exclude node_modules aggressively:
{ "files.watcherExclude": { "**/node_modules/**": true, "**/.next/**": true } }
-
Use workspace files: I have
platforms.code-workspacethat opens just the apps I'm actively working on. -
Consider separate windows: For deep work on one app, I open just that app folder, not the monorepo root.
Lesson 8: Database Migrations Need Coordination
Three of my apps share a Supabase database. This is both a feature and a footgun.
The good: I can query user data across apps. The admin panel works everywhere. Shared tables mean no data synchronization headaches.
The bad: A migration that breaks one app breaks all of them.
My approach:
- All migrations live in
supabase/migrations/ - Each table has a
sitecolumn for multi-tenancy - I test migrations against a staging database before production
- Breaking changes get feature flags, not direct deployments
Example of the multi-tenant pattern:
-- All queries filter by site SELECT * FROM course_progress WHERE site = 'freeacademy' AND user_id = $1; SELECT * FROM course_progress WHERE site = 'calisthenicsassociation' AND user_id = $1;
Lesson 9: Not Everything Belongs in the Monorepo
I initially tried to put everything in the monorepo: marketing sites, landing pages, experiments. This was a mistake.
What belongs in the monorepo:
- Production applications that share code or data
- Packages used by multiple apps
- Tooling configurations that should be consistent
What doesn't belong:
- One-off experiments
- Sites with completely different tech stacks
- Projects with different deployment schedules
- Archived projects you're not actively maintaining
I moved two experimental sites out of the monorepo. The reduced complexity was worth more than the theoretical benefits of having everything in one place.
Lesson 10: Documentation Becomes Essential
With four apps, I can't keep everything in my head. Each app has a CLAUDE.md file (yes, named for the AI assistant) that documents:
- Build and development commands
- Architecture overview
- Project structure
- Important patterns and conventions
- Environment variables
- Common tasks
This documentation serves two purposes: it helps me context-switch between apps, and it helps AI assistants understand the codebase when I ask for help.
Would I Do It Again?
Yes, but with adjustments.
What worked well:
- Consistent tooling across all apps
- Atomic commits that span multiple apps
- Shared admin panel saved significant duplication
- Single PR can update multiple apps when needed
What I'd do differently:
- Start with fewer shared packages
- Set up proper CI/CD from day one
- Establish naming conventions earlier
- Create app templates for consistent structure
When I'd recommend a monorepo:
- You have 2-5 related applications
- They share significant code or data
- The same team maintains all apps
- You want consistent tooling and patterns
When I'd avoid a monorepo:
- Apps have completely different tech stacks
- Different teams with different release schedules
- More than 5-7 applications
- You're just starting and don't know your architecture yet
The Bottom Line
A monorepo isn't a magic solution. It's a trade-off: you exchange the complexity of managing multiple repositories for the complexity of managing a single large one.
For my specific situationβfour related Next.js apps maintained by one developerβit's been the right choice. The shared tooling, atomic commits, and unified development experience outweigh the overhead.
But I won't pretend it's simple. Every benefit comes with an edge case, every convenience has a configuration file, and every "just works" moment follows hours of "why doesn't this work?"
That's the honest truth about monorepos in production.
Have questions about monorepo setups? Reach out on LinkedIn or check out the FreeAcademy courses where I teach web development fundamentals.