50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 25, 2026·8 min read

Monorepo vs Polyrepo for Next.js Projects: What Actually Matters

We've tried both. Here's the honest breakdown of when a monorepo saves you and when it buries you.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Monorepo vs Polyrepo for Next.js Projects: What Actually Matters

We had this argument at 11pm over Discord while trying to figure out how to share a types package between three Next.js apps without copying files like animals. Ștefan wanted a monorepo. I wanted to ship the feature. We did a monorepo. Six months later, I think we made the right call — but only because our situation fit it. Yours might not.

The monorepo vs polyrepo debate has the same energy as tabs vs spaces — people have strong opinions, the reasoning is often vibes-based, and the answer is almost always 'it depends.' But unlike tabs vs spaces, this decision has real consequences for your CI times, your deployment complexity, and how miserable your mornings are when something breaks across repos.

What We're Actually Comparing

A monorepo is one Git repository containing multiple projects — could be multiple Next.js apps, shared packages, a mobile app, whatever. A polyrepo is each project living in its own repository. Simple enough. But the devil is in how you manage dependencies, share code, coordinate deployments, and keep teams from stepping on each other.

For Next.js specifically, the most common monorepo setup you'll see is Turborepo (maintained by Vercel, so it plays nice with their platform) or Nx. The most common reason people reach for them: shared UI components, shared types, shared utility functions, or a marketing site + SaaS app combination where you don't want to maintain two separate design systems.

The Case for Monorepo

The killer feature of a monorepo is atomic changes. If you're updating a shared type and need to update three apps that consume it, you do it in one PR. One review. One merge. No 'which repo was it again?' and no version hell where app-a is on shared-types@1.2 and app-b is on shared-types@1.4 and they're subtly incompatible.

  • Shared code without npm publish cycles — just import from packages/ui or packages/types
  • Atomic commits across multiple apps when an interface changes
  • Single place for linting, TypeScript config, and test setup
  • Easier onboarding — clone one repo, you have everything
  • Dependency deduplication — one node_modules to rule them all (mostly)

Here's what a minimal Turborepo setup looks like for a Next.js + shared packages situation:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "type-check": {
      "dependsOn": ["^type-check"]
    }
  }
}
// packages/ui/src/button.tsx
import { ButtonHTMLAttributes } from 'react'
import { cn } from '@acme/utils'

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
}

export function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(
        'rounded-md font-medium transition-colors',
        variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
        variant === 'secondary' && 'bg-gray-100 text-gray-900 hover:bg-gray-200',
        variant === 'ghost' && 'text-gray-600 hover:bg-gray-100',
        size === 'sm' && 'px-3 py-1.5 text-sm',
        size === 'md' && 'px-4 py-2 text-base',
        size === 'lg' && 'px-6 py-3 text-lg',
        className
      )}
      {...props}
    />
  )
}

// apps/marketing/src/app/page.tsx
// Just import it — no npm publish, no versioning dance
import { Button } from '@acme/ui'

That import works because in each app's package.json you declare the workspace dependency as `"@acme/ui": "*"` and pnpm (or npm/yarn) workspaces resolves it locally. No publishing. No version mismatch. Change the Button component, both apps see it immediately.

The Case for Polyrepo

Monorepos have a cost and most blog posts forget to mention it. When you're a small team or a solo dev and you set up a monorepo because it feels professional, you're signing up for tooling overhead that will slow you down before it ever helps you.

  • CI gets complicated fast — you need proper caching or every PR rebuilds everything
  • Git history gets noisy — a change in your marketing copy shows up in the same log as a database migration
  • Access control is harder — no easy way to give a contractor access to only the marketing site
  • Some deployment platforms assume one-app-per-repo (less true now, but still)
  • Debugging build issues across packages requires understanding the whole dependency graph
  • Clone times and checkout times grow with the repo

If you have one Next.js app, use a polyrepo. Full stop. The monorepo overhead is pure cost with zero benefit. Even if you have two apps but almost no shared code, polyrepo is probably still right. The monorepo pays off when the shared code is substantial and changes frequently.

The question isn't 'monorepo or polyrepo?' — it's 'how much do my apps actually share, and how often does that shared code change?' If the answer is 'not much' or 'rarely', skip the monorepo.

The Decision Framework We Actually Use

We've shipped both setups. Here's the honest checklist we run through now:

  • Are you sharing UI components between multiple apps? → Monorepo wins
  • Are you sharing TypeScript types between a frontend and backend? → Monorepo wins
  • Is it just one app with maybe a future second app 'someday'? → Polyrepo, migrate later if needed
  • Do different parts of your system need different deploy cadences? → Monorepo can handle this, but adds complexity
  • Is your team bigger than 10 people with clear ownership boundaries? → Polyrepo or a monorepo with strict CODEOWNERS
  • Are you early-stage and moving fast? → Polyrepo. Seriously. Don't optimize for scale you don't have.

The trap we fell into early was building for the org chart we wanted rather than the one we had. Two people do not need the same repo structure as Vercel.

Making Monorepo Work Without Losing Your Mind

If you do go monorepo, a few things that save you from yourself:

First, use pnpm. It handles workspaces better than npm, the symlink structure is saner, and it's faster. Here's a minimal workspace setup:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
// package.json (root)
{
  "name": "acme",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "type-check": "turbo type-check",
    "dev:web": "turbo dev --filter=web",
    "dev:dashboard": "turbo dev --filter=dashboard"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  },
  "engines": {
    "node": ">=20",
    "pnpm": ">=9"
  }
}

Second, set up proper TypeScript project references. This is the part most tutorials skip, and it's why type-checking across packages feels broken if you skip it:

// packages/ui/tsconfig.json
{
  "extends": "@acme/typescript-config/base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src"]
}

// apps/web/tsconfig.json
{
  "extends": "@acme/typescript-config/nextjs.json",
  "compilerOptions": {
    "paths": {
      "@acme/ui": ["../../packages/ui/src"]
    }
  },
  "references": [
    { "path": "../../packages/ui" }
  ]
}

Third — and this is the one that bit us — configure Turborepo's remote cache from day one if you're using Vercel. Local cache is great for your machine but your CI will rebuild the world every run without it. Twenty-minute CI pipelines have killed more monorepos than bad architecture decisions.

Migrating Between the Two

Going from polyrepo to monorepo mid-project is annoying but doable. Going from monorepo to polyrepo is... also annoying but doable. Neither is catastrophic. We mention this because the fear of 'picking wrong' keeps people paralyzed. Pick what fits now. If you outgrow it, migrate. We've done it twice and survived both times.

To move an existing app into a monorepo: create the monorepo structure, copy the app in, update imports, update CI. Takes a weekend. The hard part isn't technical — it's updating all the deployment configs and making sure your team isn't mid-feature when you do it.

To extract something out of a monorepo into its own repo: `git filter-repo` is your friend. It rewrites history to only include commits touching a specific path. You lose the cross-package commit context, but you gain a clean repo with accurate history for that package.

Our Actual Setup (and What We'd Do Differently)

At peal.dev, we run a monorepo. We have the template gallery, a documentation site, and several shared packages (design tokens, UI components, shared TypeScript configs). The monorepo is the right call for us — we're constantly updating shared components and needing both apps to reflect those changes simultaneously.

What we'd do differently: we'd set up the remote cache on day one instead of month three. And we'd be more aggressive about the `--filter` flag in dev — running `turbo dev` and having six Next.js dev servers spin up when you only need one is the kind of thing that makes your laptop sound like a jet engine.

If you're buying a template from peal.dev and wondering which structure to adopt for your project — most of our templates are single-app, designed to drop into either a polyrepo or slot into a monorepo's `apps/` directory. The README in each template covers the monorepo setup if you need it.

Practical rule: start with polyrepo. Add the monorepo when you catch yourself copy-pasting code between repos for the third time. That copy-paste pain is the exact signal that the overhead is now worth it.

The monorepo isn't a prestige move. It's a trade — you're trading deployment simplicity and clear boundaries for code sharing and atomic changes. Make that trade consciously, when the benefit is real, not because the Vercel blog made it sound cool. And if you do make the trade, use Turborepo with pnpm, set up remote caching immediately, and use `--filter` flags obsessively. Your CI bill and your laptop fan will thank you.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders