# How to structure a .NET + Next.js monorepo without the mess

> Two toolchains in one repository works when each owns its subtree and only an HTTP contract crosses the line. The layout, the four rules that keep it clean, and the three failure patterns behind most of the pain.

# How to structure a .NET + Next.js monorepo without the mess

A polyglot monorepo works when each toolchain owns its own subtree and nothing crosses the boundary except an HTTP contract and a handful of configuration files. It becomes a mess when someone tries to make .NET and Node share a build system.

## The layout

```
apps/
  marketing-site/     Next.js: public site, SEO, pricing
  web-app/            React: authenticated customer area
  admin-panel/        React: internal operations
backend/
  api/
    src/              .NET solution: Api, Application, Domain, Infrastructure
    tests/            xUnit
config/
  branding.json  product.json  pricing.json  features.json
docs/
scripts/
package.json          npm workspaces, root only
```

Two toolchains, two roots. `npm` never looks inside `backend/`; `dotnet` never looks inside `apps/`. The `config/` directory is the only thing both sides read.

## Rule 1: one lockfile, at the root

npm workspaces put a single `package-lock.json` at the repository root and none in the individual apps. This is not cosmetic. Per-app lockfiles produce three different resolutions of the same transitive dependency, three sets of security alerts for one advisory, and a dependency bump that has to be applied three times.

Add the per-app lockfiles to `.gitignore` so a stray `npm install` in `apps/web-app` cannot commit one by accident. If your host builds a single app in isolation, install from the root lockfile rather than letting it generate its own.

Version pinning belongs at the root too, as `overrides` in the root `package.json`, so a forced version applies everywhere at once.

## Rule 2: the boundary between the stacks is HTTP, and nothing else

The temptation is a shared types package, generated from C# records, imported by TypeScript. Resist it until you have felt the pain that justifies it, because it couples the deploy cycles of two independently deployable things.

What works in practice: the API publishes an OpenAPI document, and each frontend keeps a small, hand-written response type near the code that calls the endpoint. It duplicates a few interface declarations. In exchange, the frontends have no build-time dependency on the backend, and a backend refactor cannot break a frontend build.

The corollary is that no frontend imports another frontend. Three apps that share a design language will want to share components, and the moment `web-app` imports from `admin-panel`, you no longer have three apps. You have one app with three entry points and a shared blast radius. Either duplicate the component or promote it to a real workspace package with its own `package.json`.

## Rule 3: configuration is data, in one place

Product name, brand colors, plan names and feature keys appear in the marketing site, the customer app, the admin panel, the API and the database seed. Five copies is five places to forget.

Put them in `config/*.json` at the root and have every consumer read from there: the frontends import the JSON directly, the API reads it at startup, the seed script generates from it. Renaming the product then becomes a one-line diff instead of a repository-wide search.

The test of whether this is working: rebranding the whole product should touch exactly one file.

## Rule 4: CI runs per-project, not all-or-nothing

A monorepo where every push runs the full matrix, meaning three frontend builds, `dotnet test` and the full end-to-end suite, trains the team to ignore CI, because a typo in a marketing headline takes twelve minutes to merge.

Split it by path:

- Changes under `apps/*` → lint, type-check and unit-test that app
- Changes under `backend/` → `dotnet build` and `dotnet test`
- Changes to `config/` or anything shared → everything
- End-to-end tests → on the main branch and on demand, not on every pull request

The end-to-end suite is the one that must be gated. It needs a database, a running API and built frontends; it is the slowest and flakiest thing you own. Run it where a failure is worth a human's attention, not on every draft push.

## What actually goes wrong

Three failure patterns account for most polyglot monorepo pain.

The first is a shared Dockerfile. The .NET API needs an SDK image and a multi-stage publish. The frontends need Node, and on most platforms they do not need a container at all. One Dockerfile serving both means every backend change rebuilds the frontend layers. Give the API its own Dockerfile and let the frontends deploy as static or serverless output.

The second is local development that requires everything. If working on the marketing site means starting Postgres, the API and two other frontends, people will avoid the monorepo. Each app should run standalone against a configured API URL, with a single `docker compose up` bringing up the database when you genuinely need the backend.

The third is path-based CI that silently under-triggers. The rules above are only safe if the "shared" trigger is genuinely complete. When `config/` changes and CI runs only one app, you ship a rename to two of three frontends. Keep the shared-path list short enough to audit, and default to running everything when in doubt.

## Why a monorepo at all

The payoff is atomic change. Adding a field to the API, exposing it in the admin panel and rendering it on the marketing site is one commit, one review, one deploy, one revert. Across four repositories, it is four pull requests in a required order, and a bad afternoon if you have to undo them.

You pay for that with build discipline. The four rules above are the payment. Skip them and you get the coupling of a monolith together with the tooling complexity of microservices.

