TypeScript monorepo boundaries for web, server, and shared code
TypeScript monorepo boundaries help teams share types across web, server, and shared packages without tying every change to one release.

What goes wrong when boundaries stay fuzzy
Bad TypeScript monorepo boundaries usually start with convenience. One team needs a type, grabs it from another package, then pulls in a helper too because it is already there. A week later, the web app imports server code for validation, the server imports shared UI constants, and nobody can say what each package actually owns.
That feels fast at first. Then small changes start to hit everything.
A simple rename in a server model can trigger web rebuilds, test failures in unrelated packages, and a long CI run for code that did not really change. Someone updates a shared utility for one endpoint, and now three teams need to check whether their part of the repo still works. The cost is not the rename itself. The cost is the blast radius.
The worst part is release coupling. When shared code mixes runtime logic, app rules, and types in one place, every consumer inherits the same release risk. The web team wants to ship a copy change. The server team is still testing a new auth flow. If both depend on the same package version for mixed concerns, one team waits for the other.
That is how a monorepo turns into a traffic jam. The repo is shared, but ownership is not clear. People stop trusting package boundaries because the boundaries do not mean much.
A common example looks harmless. The server package exports API types, request validators, database helpers, and a few date utilities. The web package imports the types, then starts using the validators too. Soon the client bundle pulls in code that only made sense on the server, and a backend refactor becomes a frontend problem.
The goal is simpler than many teams make it. Share types, not release schedules. Let teams reuse contracts without pulling in runtime code they do not own. If the web team changes a page, the server team should not need a version bump review. If the server team changes storage code, the design system should not rebuild.
Good boundaries do not stop sharing. They make sharing boring, predictable, and safe.
Decide what each package owns
Package ownership should answer a simple question: who decides what belongs here, and what does this package do every day? If a package has two jobs, people start treating it like a junk drawer. That is where release coupling starts.
A web package should own browser work only. Put pages, UI components, form state, client-side data fetching, and browser helpers there. If the code needs the DOM, local storage, or a React view, it belongs in web.
A server package should own backend work only. Put API handlers, database queries, background jobs, auth checks, and server config there. If the code talks to a database or needs server secrets, keep it out of web, even if the import feels convenient.
The shared package should stay small and boring. It is not a second app. Use it for contracts that both sides need to agree on, such as request and response types, event names, and common domain terms like OrderStatus or UserProfile. In TypeScript monorepo boundaries, this package is where teams share meaning without sharing every implementation detail.
Make ownership explicit
Write the rules down once, then keep them close to the repo:
- each package has one clear purpose
- one team or person owns it
- another team cannot change it without approval
- consumers can request changes, but owners make the call
- forbidden code is listed in plain language
A small example makes this easier. Say one team builds the customer dashboard, one team runs the API, and one team manages billing. The dashboard package should not import billing logic just to reuse a helper. The API package should not reach into dashboard components to reuse a formatter. If all three need the same invoice type, put that type in a shared contracts package and give that package a named owner.
That last part matters. When nobody owns a package, everybody edits it. When everybody edits it, no one protects the boundary. Clear ownership keeps changes smaller, reviews faster, and releases less tangled.
Split packages by change rate, not by folder type
Teams usually start with neat folders like web, server, and shared. That looks clean on day one, but it often turns into a mess later. The problem is not where the code runs. The problem is how often it changes, who owns it, and how risky each change is.
A checkout UI might change every week. An API contract for orders might change every few months. A currency formatter or date helper may stay the same for a long time. If all of that lives in one big shared package, every small UI tweak can force other teams to retest and republish code they did not touch.
That is where TypeScript monorepo boundaries start to matter. Good boundaries let teams share types and small utilities without tying every release together.
A simple rule works well: put fast-moving code in packages that can move fast, and keep stable contracts in packages that change rarely. Product code, experiment code, and UI flows usually belong close to the app that owns them. Types that define request and response shapes can live in a smaller contract package with a slower release pace.
A giant shared package is often a warning sign. It tends to collect everything: API types, React hooks, validation code, helper functions, test fixtures, maybe even server logic. Then one team needs a patch, bumps the package, and three other teams get dragged into the same release.
Split sooner when you see these patterns:
- one package has both UI code and backend models
- one team changes files every day while another almost never touches them
- a patch for one app forces version bumps across unrelated apps
- people say "just put it in shared" without naming an owner
A small example makes this clearer. If the web team ships pricing experiments every few days, keep that code in the web package. If the server team owns invoice rules and exposes typed request and response contracts, keep those contracts in their own package. If both teams use a tax ID type, that type can live in a tiny package by itself instead of inside a grab bag of unrelated helpers.
Folder names help people browse the repo. Release timing helps teams work without blocking each other. Pick the second one first.
Share types without sharing runtime code
When web and server packages import each other's runtime code, release timing gets messy fast. A safer approach for TypeScript monorepo boundaries is to share the agreement between packages, not the code that executes business rules.
A small contracts package usually does enough. It holds request and response shapes, common IDs, status values, and error formats. The web team can build forms and API calls against those types, while the server team can change internal logic without breaking every consumer.
A simple contracts package might include types like CreateInvoiceRequest, InvoiceResponse, InvoiceId, and InvoiceStatus. Those names describe data that crosses a package boundary. They do not describe how the server stores invoices, calculates tax, or decides who can approve a refund.
What belongs there is usually narrow:
- request and response types
- shared IDs and status values
- pagination and error shapes
- validation rules only if both sides truly run them
That last point matters. Teams often put schema parsing, database mapping, formatting helpers, and permission checks into the shared layer because it feels convenient. It rarely stays small. Soon the web package depends on server decisions, and a harmless refactor turns into a coordinated release.
Plain types should come first. They are light, easy to review, and cheap to version. If both sides really need runtime validation, add it with care and keep it close to the contract itself. A schema that checks `
Set up a simple boundary model step by step
Start with three packages and one rule that never changes. Put browser code in web, backend code in server, and shared request and response shapes in contracts.
Keep contracts small on purpose. It should hold types that both sides need to agree on, such as API payloads, IDs, enums, and maybe a few shared schemas. If code only runs on the server, leave it in server. If it only helps the UI render, keep it in web.
That gives you a clean import graph. The web app can import contracts, and the server can import contracts, but web should never reach into server. The server should also stay out of web. When teams follow that rule, one frontend change does not drag backend code into the same release.
For most teams, the setup looks like this:
- Create clear package names and TypeScript path aliases so imports are easy to spot.
- Add lint rules that block forbidden imports, such as anything from
webintoserveror the other way around. - Run package-level type checks and builds in CI so boundary breaks fail fast.
- Keep
contractsunder tighter review, because changes there can affect both sides.
Path rules help people write the right import. Lint rules catch mistakes before review. Build checks stop accidental coupling from slipping into main. You want all three. Good TypeScript monorepo boundaries depend less on team memory and more on boring, automatic checks.
Independent releases matter once teams move at different speeds. If the web team ships every day but the server team ships once a week, give contracts its own version. Then a server update can stay in server, and a web-only fix can stay in web. Only changes to shared contracts need coordination.
A small team can start with one repo and one release flow, then split package versions later. That is often the better path. You do not need a complicated setup on day one. You need a package graph that is obvious, enforced, and hard to break by accident.
A realistic example with three teams
Picture a monorepo with three teams working on one checkout flow. They share one customer journey, but they do not need to share every release.
A clean split might look like this:
contracts-checkoutownsCart,CartLine,Address, andOrderInputweb-checkoutowns the page, form state, and UI behaviorapi-ordersowns request handling and order creation, whilebilling-coreowns payment rules and provider code
The web team builds the checkout page. When it reads the cart and sends an order, it imports the cart and order shapes from contracts-checkout. That keeps the form honest. If the cart needs a coupon field or a shipping method, the team changes the contract package first and updates the page against that change.
The API team uses the same OrderInput type at the request boundary. That gives both sides one shared shape for the payload. The web team does not guess what the server expects, and the API team does not hand-write a second version of the same object.
The billing team does something different. It does not expose its inner code to the rest of the repo. The package exports a small public surface, such as chargeOrder() or capturePayment(), and keeps gateway adapters, retry rules, fraud checks, and provider mapping private. The web app should never import billing internals. The API should call billing through that narrow public entry point.
That separation pays off when billing changes. Say the billing team adds a second payment provider, changes retry timing, or rewrites tax handling. If the public contract stays the same, only billing-core ships. The checkout page does not need a release. The order API does not need a release either.
This is the point of TypeScript monorepo boundaries in practice. Teams share data shapes where it helps, and they hide runtime logic where change is frequent. You get fewer surprise breakages, and one team can move on Tuesday without dragging two other teams into the same deploy.
Mistakes that create release coupling
Release coupling usually starts with a small shortcut. One team borrows a type, a helper, or a model "just for now," and six months later three packages have to ship together.
That is where TypeScript monorepo boundaries often fail. The code still compiles, but team ownership gets blurry and simple changes turn into cross-team work.
A shared package should not hold database models. Database tables, ORM entities, and query shapes belong to the server side because they change for server reasons. If the web app imports those models, a harmless database refactor can suddenly break the front end even when the API did not change.
The same problem shows up when the web package imports server utilities for convenience. A date formatter might look harmless, but server utilities often pull in config, auth rules, filesystem code, or database assumptions over time. Then the web app depends on server release timing.
A "shared" folder can also become a junk drawer. When every helper lands there, nobody knows what the package owns anymore. Teams start treating shared code as public by default, and that is when small edits need too many approvals.
A better rule is simple: only put code in a shared package if two teams need it and both can explain why it is stable.
Contract types need extra care. If the server team renames an internal field, swaps an ORM, or reorganizes service code, they should not change public request and response types unless the actual contract changed. If the API still returns the same data, keep the contract steady and refactor behind it.
A realistic failure looks like this:
- The server team moves from one database library to another.
- They update shared models to match the new schema helpers.
- The web app now needs changes, even though no screen changed.
- QA retests both apps.
- Everyone calls it "one release" when it should have been one server-only change.
Approval habits can create the same mess. If one team reviews every package by default, ownership turns into a queue. Shared packages need clear owners, but web, server, and internal tools should not all wait for the same person unless the change actually crosses a boundary.
If a package forces unrelated teams to coordinate every week, the boundary is probably wrong.
Quick checks before adding a dependency
A new import can look harmless. In practice, it decides who owns change, who waits on release day, and who gets dragged into bugs they did not create. Good TypeScript monorepo boundaries show up in these small choices.
Pause before you add the dependency. A two minute check now can save days of cleanup later.
- Ask who owns the package. If no team clearly owns it, treat that as a warning. Shared code without an owner usually turns into slow reviews and messy fixes.
- Check whether you need a type or actual runtime code. A shared interface, schema, or enum is much lighter than pulling in helpers, config, or business logic.
- Check whether the change forces another team to release. If the web team cannot ship until the server team republishes a package, the boundary is already too tight.
- Check whether the contract stays stable after this change. If the import depends on fields or behavior that change every sprint, you are sharing churn, not reuse.
- Check whether the dependency direction still makes sense. Shared packages should sit low in the graph. Web and server packages can depend on shared contracts, but shared contracts should not depend on UI code, database access, or request handlers.
A small example makes this easier to judge. Say the web team wants to import a server package just to reuse a UserRole type. That usually looks efficient for about a week. Then the server team changes a validation helper, publishes a new version, and the web app gets forced into an update it did not ask for.
A cleaner move is to put only the contract in a shared package if both sides truly use it. If the type is still moving fast, copying ten lines can be the better call. Small duplication is often cheaper than permanent release coupling.
Teams that build fast learn to protect their dependency graph. Every new edge should have a clear owner, a clear reason, and a release path that does not slow everyone else down.
Next steps for a monorepo that can grow
Start with evidence, not opinions. Pull a week or two of import data from the repo and mark the places where teams keep reaching across package lines. You will usually find the same trouble spots: UI code importing server helpers, shared packages carrying app logic, or one team waiting on another team’s release for a tiny type change.
Then make one small move first. Create a narrow shared package for contracts only: request and response types, domain enums, and a few schema helpers if both sides truly need them. Keep runtime code out of that package. This single split often removes a lot of release coupling without forcing a large rewrite.
A short review with each team helps more than a long architecture doc. Ask a few plain questions:
- Which package can this team change without asking permission?
- Which imports slow another team down?
- Which shared types change every sprint, and why?
- Which package needs its own version and release notes?
- Which new imports should CI block right away?
After that, add guardrails. Use lint rules, tsconfig settings, and a simple CI check to block new boundary leaks before they spread. For most teams, better TypeScript monorepo boundaries come from boring rules that everyone can follow: web imports web-safe packages, server imports server code and contracts, and shared packages stay small.
Ownership also needs a release plan. Write down who approves changes in each package, who publishes it, and what happens when a contract changes. If a type update forces three teams to stop and coordinate, the package is still too broad.
An outside review can help when the repo already has years of mixed decisions inside it. Oleg Sotnikov, a fractional CTO and startup advisor, works on practical architecture and lean delivery, and he can help teams redesign monorepo boundaries without turning the cleanup into a long pause in shipping.
A good result looks simple. Three teams work in the same repo, share types where it makes sense, and ship routine changes without waiting on each other.