George Ongoro.

Insights, engineering, and storytelling. Exploring the intersection of technology and creativity to build the future of the web.

Navigation

Home FeedFor YouAboutContactRSS FeedUse my articles on your site

Legal

Privacy PolicyTerms of ServiceAdmin Portal

Stay Updated

Get the latest engineering insights delivered to your inbox.

© 2026 George Ongoro. All rights reserved.

System Online
    Hometutorials-howto

    Structuring a Next.js Monorepo for Multiple Apps

    February 19, 20267 min read
    tutorials-howto
    Structuring a Next.js Monorepo for Multiple Apps
    Cover image for Structuring a Next.js Monorepo for Multiple Apps

    Managing multiple Next.js applications in a single repository can feel like herding cats. Without a deliberate structure, shared code becomes duplicated, dependency versions drift apart, and developer experience suffers. A well-architected monorepo solves all of these problems and unlocks a level of consistency that is difficult to achieve otherwise.

    This article walks through a practical, production-ready approach to structuring a Next.js monorepo using Turborepo and pnpm workspaces, two tools that have become the de facto standard for this kind of setup.

    Why a Monorepo?

    Before diving into the structure, it is worth clarifying what a monorepo actually buys you:

    • Shared code without publishing: UI components, utility functions, and TypeScript types can be consumed across apps without going through a registry.
    • Atomic commits: A single pull request can update a shared library and all consuming apps simultaneously.
    • Unified tooling: ESLint, Prettier, TypeScript, and testing configurations live in one place and propagate everywhere.
    • Faster CI with caching: Tools like Turborepo cache task outputs so unchanged packages are never rebuilt.

    A monorepo is not always the right choice. If your applications are entirely unrelated or owned by separate teams with no shared code, independent repositories may serve you better. But for most product companies with multiple frontend surfaces, a monorepo pays dividends quickly.

    Choosing Your Tools

    Package Manager: pnpm

    pnpm is the preferred package manager for monorepos because of its efficient disk usage (content-addressable storage), strict dependency resolution that avoids phantom dependencies, and first-class workspace support.

    Build Orchestrator: Turborepo

    Turborepo sits on top of your package manager and orchestrates tasks across packages. Its remote caching feature means that CI pipelines share a build cache, so tasks that were already completed on another developer's machine or a previous pipeline run are skipped entirely.

    Repository Structure

    A clean monorepo separates concerns into three top-level directories: apps, packages, and configuration files at the root.

    my-monorepo/
    ├── apps/
    │   ├── web/                  # Marketing site (Next.js)
    │   ├── dashboard/            # Internal dashboard (Next.js)
    │   └── docs/                 # Documentation site (Next.js)
    ├── packages/
    │   ├── ui/                   # Shared component library
    │   ├── config-typescript/    # Shared tsconfig presets
    │   ├── config-eslint/        # Shared ESLint configs
    │   └── utils/                # Shared utility functions
    ├── turbo.json
    ├── package.json
    └── pnpm-workspace.yaml
    

    The apps/ Directory

    Each folder inside apps/ is a standalone Next.js application with its own package.json, next.config.js, and everything else a Next.js app normally needs. The key difference is that these apps can import from packages/ as if they were any other dependency.

    The packages/ Directory

    This is where shared code lives. Each package is a small, focused module with its own package.json. They are not published to npm; they are consumed directly through workspace links.

    Setting Up pnpm Workspaces

    At the root of the repository, create a pnpm-workspace.yaml file that tells pnpm which directories contain packages:

    # pnpm-workspace.yaml
    packages:
      - "apps/*"
      - "packages/*"
    

    The root package.json should mark itself as private and define scripts that delegate to Turborepo:

    {
      "name": "my-monorepo",
      "private": true,
      "scripts": {
        "build": "turbo build",
        "dev": "turbo dev",
        "lint": "turbo lint",
        "typecheck": "turbo typecheck"
      },
      "devDependencies": {
        "turbo": "^2.0.0"
      },
      "packageManager": "pnpm@9.0.0"
    }
    

    Configuring Turborepo

    The turbo.json file at the root defines the task pipeline. It tells Turborepo how tasks relate to each other and which outputs to cache.

    {
      "$schema": "https://turbo.build/schema.json",
      "tasks": {
        "build": {
          "dependsOn": ["^build"],
          "outputs": [".next/**", "!.next/cache/**", "dist/**"]
        },
        "dev": {
          "cache": false,
          "persistent": true
        },
        "lint": {
          "dependsOn": ["^build"]
        },
        "typecheck": {
          "dependsOn": ["^build"]
        }
      }
    }
    

    The ^build syntax means "run build in all dependencies first." This ensures that if apps/dashboard depends on packages/ui, the UI package is built before the dashboard attempts to compile.

    Creating a Shared Package

    Let's walk through setting up packages/ui as a shared component library.

    packages/ui/package.json

    {
      "name": "@repo/ui",
      "version": "0.0.1",
      "private": true,
      "main": "./src/index.ts",
      "types": "./src/index.ts",
      "exports": {
        ".": "./src/index.ts"
      },
      "devDependencies": {
        "typescript": "^5.0.0"
      },
      "peerDependencies": {
        "react": "^18.0.0",
        "react-dom": "^18.0.0"
      }
    }
    

    Note: Exporting TypeScript source directly (rather than a compiled dist/) is a common pattern in Next.js monorepos. Next.js can transpile packages natively using the transpilePackages option, which eliminates a separate build step for internal packages during development.

    packages/ui/src/index.ts

    export { Button } from "./components/Button";
    export { Card } from "./components/Card";
    export type { ButtonProps } from "./components/Button";
    

    Consuming the Package in an App

    Inside apps/dashboard/package.json, add the shared package as a dependency using the workspace protocol:

    {
      "dependencies": {
        "@repo/ui": "workspace:*"
      }
    }
    

    Then enable transpilation in apps/dashboard/next.config.js:

    /** @type {import('next').NextConfig} */
    const nextConfig = {
      transpilePackages: ["@repo/ui"],
    };
    
    module.exports = nextConfig;
    

    Now components from @repo/ui can be imported directly in any file inside the dashboard app:

    import { Button } from "@repo/ui";
    

    Sharing TypeScript Configuration

    Rather than duplicating tsconfig.json across every app and package, create a shared base configuration in packages/config-typescript.

    packages/config-typescript/base.json

    {
      "$schema": "https://json.schemastore.org/tsconfig",
      "compilerOptions": {
        "target": "ES2017",
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "incremental": true
      },
      "exclude": ["node_modules"]
    }
    

    Each app or package then extends this base:

    apps/dashboard/tsconfig.json

    {
      "extends": "@repo/config-typescript/base.json",
      "compilerOptions": {
        "plugins": [{ "name": "next" }],
        "paths": {
          "@/*": ["./src/*"]
        }
      },
      "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
      "exclude": ["node_modules"]
    }
    

    Sharing ESLint Configuration

    The same pattern applies to ESLint. Create a packages/config-eslint package with a base config and framework-specific extensions.

    packages/config-eslint/next.js

    const { resolve } = require("node:path");
    
    const project = resolve(process.cwd(), "tsconfig.json");
    
    /** @type {import("eslint").Linter.Config} */
    module.exports = {
      extends: [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:react/recommended",
        "next/core-web-vitals",
      ],
      settings: {
        "import/resolver": {
          typescript: { project },
        },
      },
      rules: {
        "react/react-in-jsx-scope": "off",
      },
    };
    

    Each Next.js app then references it in its own .eslintrc.js:

    /** @type {import("eslint").Linter.Config} */
    module.exports = {
      extends: [require.resolve("@repo/config-eslint/next")],
      parserOptions: {
        project: true,
      },
    };
    

    Running Tasks

    With everything wired up, Turborepo handles orchestration. From the root directory:

    # Start all apps in development mode
    pnpm dev
    
    # Build all apps and packages (respecting dependency order)
    pnpm build
    
    # Build only the dashboard app and its dependencies
    pnpm build --filter=dashboard
    
    # Run lint across the entire repo
    pnpm lint
    
    # Add a dependency to a specific app
    pnpm add zod --filter=dashboard
    

    The --filter flag is one of Turborepo's most useful features. It lets you scope any command to a specific app or package, which is essential when you do not want to rebuild everything during local development.

    Tips for Keeping the Monorepo Healthy

    Keep packages small and focused. A utils package that grows to contain hundreds of unrelated functions becomes a liability. Prefer splitting into domain-specific packages like utils-date, utils-format, or utils-api.

    Enforce boundaries. Use ESLint rules or Turborepo's boundaries feature to prevent packages/ui from importing from apps/dashboard. Packages should never depend on apps.

    Version internal packages consistently. Using workspace:* for all internal packages ensures that your workspace always resolves to the local version rather than a published one.

    Enable remote caching early. Turborepo's remote cache can be connected to Vercel or a self-hosted option. Setting it up before your team grows means everyone benefits from shared build caches from day one.

    Document the structure. A short CONTRIBUTING.md at the root explaining where to add new apps, how to create packages, and how to run tasks saves new team members hours of confusion.

    A well-structured Next.js monorepo eliminates the overhead of managing multiple repositories while giving every app access to a consistent set of shared building blocks. The combination of pnpm workspaces for dependency management and Turborepo for task orchestration handles the hard parts automatically, letting your team focus on shipping features rather than managing infrastructure.

    Start with the structure outlined here, add packages as shared needs emerge, and lean on Turborepo's caching to keep your build times fast as the repository scales.

    George Ongoro
    George Ongoro

    Blog Author & Software Engineer

    I'm George Ongoro, a passionate software engineer focusing on full-stack development. This blog is where I share insights, engineering deep dives, and personal growth stories. Let's build something great!

    View Full Bio

    Related Posts

    Comments (0)

    Join the Discussion

    Please login to join the discussion