6 min read

Monorepo Mastery: Managing Next.js Front‑ends and Python Microservices with Turborepo & PNPM

Monorepo Mastery: Managing Next.js Front‑ends and Python Microservices with Turborepo & PNPM

Introduction

Modern web platforms often consist of a React‑based UI (commonly built with Next.js) and a set of backend services written in Python. Keeping these pieces in separate repositories can quickly become a coordination nightmare: version mismatches, duplicated tooling, and cumbersome CI pipelines.

A monorepo—a single repository that houses all related codebases—offers a clean solution, but it introduces its own challenges around dependency management, build isolation, and developer ergonomics.

In this article we’ll walk through a practical setup that uses Turborepo as the task runner and PNPM as the package manager to orchestrate a monorepo containing:

  • A Next.js application (apps/web)
  • One or more Python microservices (services/auth, services/payments)
  • Shared TypeScript utilities (packages/ui, packages/config)

You’ll see how to:

  1. Bootstrap the repo with Turborepo and PNPM.
  2. Wire up cross‑language scripts (e.g., lint, test, build) while keeping builds fast.
  3. Share configuration and type definitions between the front‑end and back‑end.
  4. Optimize CI/CD pipelines for incremental builds.

By the end you’ll have a reproducible scaffold you can adapt to any stack that mixes JavaScript/TypeScript and Python.


1. Why Turborepo + PNPM?

Feature Turborepo PNPM
Incremental task caching
Parallel execution
Remote caching (optional)
Workspace‑aware dependency graph
Strict node_modules layout (no duplication)
Support for non‑JS packages (via pnpm-workspace.yaml)
  • Turborepo excels at orchestrating tasks (build, lint, test) across many packages, only re‑running what changed.
  • PNPM stores a single copy of each npm package in a global store and creates symlinks, dramatically reducing disk usage—critical when you have many UI libraries and shared configs.

Together they give you fast, deterministic builds while still allowing you to keep Python services in the same repo.


2. Repository Layout

my-monorepo/
├─ .gitignore
├─ pnpm-workspace.yaml
├─ turbo.json
├─ packages/
│  ├─ ui/                # shared React components (TS)
│  └─ config/            # eslint, prettier, tsconfig, etc.
├─ apps/
│  └─ web/               # Next.js app
├─ services/
│  ├─ auth/              # FastAPI service
│  └─ payments/          # Flask service
└─ scripts/
   └─ lint.sh            # wrapper for linting all languages

All JavaScript/TypeScript code lives under packages/ and apps/. Python services sit under services/.

The scripts/ folder contains tiny shell wrappers that Turborepo can invoke, keeping the package.json files clean.


3. Bootstrapping the Monorepo

3.1 Install PNPM and Turborepo

npm i -g pnpm
pnpm add -g turbo

3.2 Initialise PNPM workspace

Create pnpm-workspace.yaml:

packages:
  - "packages/*"
  - "apps/*"
  - "services/*"

PNPM will treat every folder matching those globs as a workspace package. For Python services we’ll still have a package.json (empty) so PNPM can include them in the graph.

3.3 Initialise Turborepo

turbo init

This creates a turbo.json with a default pipeline. Replace it with a more tailored version:

{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "lint": {
      "outputs": []
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "type-check": {
      "outputs": []
    }
  }
}
  • ^build means “run the build of any dependent package first”.
  • outputs tells Turborepo what files constitute the result of a task, enabling caching.

4. Adding the Next.js App

pnpm create next-app apps/web --typescript

Inside apps/web/package.json add Turborepo scripts:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "type-check": "tsc --noEmit"
  }
}

4.1 Consuming Shared UI

// apps/web/pages/index.tsx
import { Button } from "@my-monorepo/ui";

export default function Home() {
  return (
    <main>
      <h1>Welcome to the monorepo demo</h1>
      <Button>Click me</Button>
    </main>
  );
}

Because ui is a workspace package, PNPM automatically creates a symlink in node_modules/@my-monorepo/ui.


5. Adding a Python Microservice

Create a minimal FastAPI service:

mkdir -p services/auth && cd services/auth
python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn

Add a main.py:

# services/auth/main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "ok"}

Create a package.json so PNPM knows about the service (no dependencies needed):

{
  "name": "@my-monorepo/auth",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "uvicorn main:app --reload",
    "lint": "ruff .",
    "test": "pytest"
  }
}
Tip: Use a Python linter like ruff and a test runner like pytest. They can be installed in the virtualenv and invoked from the scripts section.

Now the service is part of the Turborepo graph.


6. Cross‑Language Scripts

6.1 Central Lint Script

Create scripts/lint.sh:

#!/usr/bin/env bash
set -euo pipefail

# Lint JS/TS
pnpm --filter "./apps/*" run lint
pnpm --filter "./packages/*" run lint

# Lint Python
for svc in services/*; do
  if [[ -f "$svc/package.json" ]]; then
    pnpm --filter "$svc" run lint || true
  fi
done

Add a top‑level script in the root package.json:

{
  "scripts": {
    "lint": "bash ./scripts/lint.sh"
  }
}

Running pnpm lint now lints all code, regardless of language.

6.2 Build Pipeline

# Build all JS/TS packages
pnpm --filter "./apps/*" run build
pnpm --filter "./packages/*" run build

# Build Python services (optional step, e.g., compile to wheels)
for svc in services/*; do
  if [[ -f "$svc/package.json" ]]; then
    pnpm --filter "$svc" run build || true
  fi
done

Because Turborepo already knows the dependency graph, you can replace the loops with a single command:

turbo run build

Turborepo will:

  1. Run type-check for any TypeScript package that changed.
  2. Run build for the Next.js app only if its source or a dependent UI package changed.
  3. Skip Python services if no Python files changed (thanks to the outputs field you can add dist/** or similar).

7. Sharing Types Between Front‑End and Back‑End

Even though the back‑end is Python, you can still share runtime‑checked data contracts using JSON Schema generated from TypeScript types.

7.1 Generate JSON Schema

Install ts-json-schema-generator in a shared packages/config:

pnpm add -D ts-json-schema-generator

Create packages/config/generate-schema.ts:

import { createGenerator } from "ts-json-schema-generator";
import { writeFileSync } from "fs";

const config = {
  path: "src/types.ts",
  tsconfig: "tsconfig.json",
  type: "*", // generate for all exported types
};

const generator = createGenerator(config);
const schema = generator.createSchema(config.type);
writeFileSync("../schemas/types.json", JSON.stringify(schema, null, 2));

Add a script:

{
  "scripts": {
    "gen:schema": "ts-node ./generate-schema.ts"
  }
}

Run pnpm --filter @my-monorepo/config gen:schema. The resulting schemas/types.json can be checked into the repo.

7.2 Validate in Python

Install jsonschema in the Python service:

pip install jsonschema
# services/auth/validation.py
import json
from jsonschema import validate, ValidationError

with open("../schemas/types.json") as f:
    SCHEMA = json.load(f)

def validate_user(payload: dict):
    try:
        validate(instance=payload, schema=SCHEMA["definitions"]["User"])
    except ValidationError as exc:
        raise ValueError(f"Invalid user payload: {exc.message}")

Now both sides rely on a single source of truth for data shapes, reducing runtime bugs.


8. Optimising CI/CD

8.1 Caching with Turborepo Remote Cache

In GitHub Actions:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
        with:
          version: 9
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      - name: Restore Turborepo cache
        uses: actions/cache@v3
        with:
          path: .turbo
          key: ${{ runner.os }}-turbo-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-turbo-
      - name: Run build
        run: turbo run build --filter=...

The .turbo folder stores task caches, so subsequent runs only rebuild changed packages.

8.2 Parallel Test Execution

Turborepo can run test tasks in parallel across packages:

turbo run test --parallel

Combine with pnpm exec jest --runInBand for JS tests and pytest -n auto for Python tests to fully utilise CI cores.


9. Common Pitfalls & How to Avoid Them

Symptom Likely Cause Fix
pnpm install hangs on a Python service Service has a package.json but no node_modules needed Add "private": true and keep scripts minimal; PNPM will skip unnecessary installs.
Turborepo rebuilds everything on every commit outputs field missing or too broad Explicitly list build artifacts (dist/**, .next/**).
TypeScript UI components cannot import from @my-monorepo/config Workspace alias not configured Add "paths" in tsconfig.base.json and reference it in each package’s tsconfig.json.
Python service cannot find generated JSON schema Relative path points outside repo Use a monorepo‑root absolute import (import pathlib; SCHEMA_PATH = pathlib.Path(__file__).parents[2] / "schemas/types.json").

10. Scaling Beyond Two Services

When you add more microservices (e.g., services/search, services/notifications), the same pattern applies:

  1. Create a minimal package.json with scripts.
  2. Add the folder to pnpm-workspace.yaml (already covered by the glob).
  3. Define a Dockerfile that copies only the service’s code and the shared schemas/ directory.
  4. Update Turborepo pipeline if you need a new task (e.g., docker-build).

Because Turborepo’s graph is language‑agnostic, it will automatically understand that services/search depends on packages/config if you reference the schema in its code.


11. Recap

  • Monorepo gives you a single source of truth for versioning, CI, and shared utilities.
  • Turborepo orchestrates builds, linting, and testing with incremental caching.
  • PNPM keeps node_modules lean and provides a workspace graph that works alongside Python services.
  • Shared JSON Schema bridges the type gap between TypeScript and Python, ensuring contract fidelity.
  • CI optimisations (remote cache, parallel tasks) make the workflow fast enough for large teams.

By following the steps above you’ll have a robust, scalable foundation for a product that ships a Next.js front‑end and multiple Python microservices—all from a single repository. The pattern is flexible enough to accommodate additional languages (Go, Rust) or deployment targets (Kubernetes, serverless) without a major re‑architect.

Happy coding! 🚀