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:
- Bootstrap the repo with Turborepo and PNPM.
- Wire up cross‑language scripts (e.g., lint, test, build) while keeping builds fast.
- Share configuration and type definitions between the front‑end and back‑end.
- 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": []
}
}
}
^buildmeans “run the build of any dependent package first”.outputstells 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:
- Run
type-checkfor any TypeScript package that changed. - Run
buildfor the Next.js app only if its source or a dependent UI package changed. - Skip Python services if no Python files changed (thanks to the
outputsfield you can adddist/**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:
- Create a minimal
package.jsonwith scripts. - Add the folder to
pnpm-workspace.yaml(already covered by the glob). - Define a Dockerfile that copies only the service’s code and the shared
schemas/directory. - 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! 🚀
Member discussion