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.