Skip to content

Setting Up a Monorepo with Turborepo

Aman Kumar Singh5 min read
Part 2 of 40From the Full Stack SaaS Masterclass series
Setting Up a Monorepo with Turborepo — article by Aman Kumar Singh

For a long time I resisted monorepos. They sounded like the kind of thing big companies with platform teams did, not something a small SaaS needed. Then I spent an afternoon manually copy-pasting a TypeScript type from my backend into my frontend for the fourth time that week, and I gave up resisting.

That shared-types problem is the whole reason a full-stack TypeScript project wants a monorepo. When your API returns a User, your frontend should know exactly what a User looks like, from the same source of truth rather than a comment or a copy. A monorepo makes that trivial. And Turborepo makes the monorepo pleasant instead of painful.

This is part two of the Full Stack SaaS Masterclass. We picked the stack; now we set up the repository it all lives in.

Why a monorepo, specifically for this

Let me be clear about what a monorepo buys you here. These benefits are concrete:

Shared types that can't drift. Your backend defines the shape of your data once, your frontend imports it, and if the backend changes the shape, the frontend fails to compile until you fix it. That compile error is a feature. It catches a whole class of bugs before they exist.

One place to run everything. npm install once at the root. Start the frontend and backend with one command. No juggling two repos that have to be checked out, updated, and kept in sync by hand.

Atomic changes across the stack. Add a field to the API and update the UI that uses it in a single commit, single pull request, single review. When those live in separate repos, that same change becomes two PRs and a coordination dance.

I'll also be honest about the cost: a monorepo is slightly more setup upfront, and if you ever genuinely need to open-source just the frontend or hand one piece to a different team, splitting it is annoying. For a solo founder or small team building one product, though, the trade lands firmly on the monorepo side.

What Turborepo actually does

A monorepo without a build tool gets slow. You change one line in the frontend and your scripts rebuild everything, including the backend that didn't change. Multiply that by a hundred times a day and it's death by a thousand rebuilds.

Turborepo fixes this with two ideas. First, it understands the dependency graph between your packages, so it only rebuilds what actually changed and what depends on it. Second, it caches task results. If you've already built or tested something and nothing it depends on changed, Turborepo hands you the previous result instantly instead of redoing the work.

That caching is the part that feels like magic the first time. Run your tests, change nothing, run them again: the second run finishes in a fraction of a second because Turborepo knows the answer can't have changed.

The layout

Here's the structure I use. Apps are deployable things; packages are shared code they both use.

my-saas/
├── apps/
│   ├── web/          # Next.js frontend
│   └── api/          # NestJS backend
├── packages/
│   ├── types/        # shared TypeScript types (the whole point)
│   ├── config/       # shared eslint / tsconfig
│   └── ui/           # shared React components (optional, later)
├── package.json      # workspace root
├── turbo.json        # Turborepo pipeline config
└── package-lock.json

The apps versus packages split is a convention worth keeping: apps are things you deploy, packages are things apps import. When you're unsure where something goes, ask "do I deploy this on its own?" If no, it's a package.

Wiring it up

Start with the root package.json. The key line is workspaces, which tells npm that folders under apps and packages are part of one workspace and can depend on each other by name.

{
  "name": "my-saas",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "latest"
  }
}

Then turbo.json describes how tasks relate. The important field is dependsOn: "^build" means "before building this package, build the packages it depends on first." That ^ is Turborepo reading your dependency graph for you.

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {}
  }
}

Now the shared types package. It's almost embarrassingly small. That's the point.

// packages/types/src/index.ts
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: string;
}

Your NestJS backend imports User to type an API response. Your Next.js frontend imports the same User to type the data it renders. One definition, both ends, guaranteed in sync. Change name to fullName here and both apps refuse to compile until they're updated. That safety net, running constantly and for free, is why the whole setup is worth it.

A caveat before you go all-in

Don't over-build the monorepo on day one. I've seen people spend a week crafting the perfect package boundaries (a utils package, a constants package, a hooks package) for an app that has three files. That's the same premature-complexity trap the last article warned about, just wearing different clothes.

Start with two apps and one types package. Add more packages only when you catch yourself copy-pasting something for the second or third time. Let the structure grow from real duplication rather than imagined tidiness.

Next in the series, we set up the frontend properly: a Next.js app inside this monorepo, wired to consume those shared types.

Key takeaways

  • A monorepo's biggest payoff for full-stack TypeScript is shared types that can't silently drift between backend and frontend.
  • It also gives you one install, one set of commands, and atomic cross-stack changes in a single PR.
  • Turborepo keeps a monorepo fast by rebuilding only what changed and caching task results.
  • Use the `apps` (deployable) vs `packages` (shared) convention; if you don't deploy it alone, it's a package.
  • Start minimal, with two apps and a `types` package, and add packages only when real duplication appears.

Frequently asked questions

Do I really need a monorepo for a small SaaS?

If your frontend and backend are both TypeScript, a monorepo pays for itself immediately through shared types and single-PR changes across the stack. For a small full-stack team it's usually the right call, not overkill.

What problem does Turborepo solve that npm workspaces don't?

npm workspaces link the packages together, but Turborepo adds fast, incremental builds: it rebuilds only what changed and caches task results, so repeated builds and tests are near-instant.

What's the difference between apps and packages?

Apps are deployable units (your web frontend, your API). Packages are shared code that apps import (types, config, UI components). If you don't deploy something on its own, it belongs in packages.

How do shared types stay in sync between frontend and backend?

Both import the same type from a shared package. If the definition changes, both apps fail to compile until updated, so drift is impossible rather than merely discouraged.

Can I split a monorepo later if I need to?

Yes, though it takes effort. If you're confident you'll need independent repos for different teams soon, weigh that. For a single product and team, the monorepo's daily benefits outweigh the rare split cost.

Related articles

A Folder Structure That Scales for Full Stack SaaS — Aman Kumar Singh
Error Handling and Loading States — Aman Kumar Singh
An Enterprise Folder Structure for Next.js — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.