Split a large React app with packages and route ownership
Learn how to split a large React app with package boundaries, clear route ownership, and calm release rules before you add runtime layers.

Why one React app starts slowing teams down
A React app does not slow a team down just because it gets big. Some large apps still move quickly. The real problem starts when nobody can say, "this part belongs to this team, and changes stop here."
Shared folders usually create the first mess. A components or utils directory starts as a convenient place for common code, then turns into a junk drawer. People add half-reusable hooks, one-off helpers, layout pieces, and copied patterns. A few months later, nobody knows what is safe to change, so every edit feels risky.
Then even small UI work spreads further than it should. Changing a billing button might mean touching a shared form component, a validation helper, a route wrapper, a feature flag file, and a test setup used by three other areas. The code may still work, but the cost of a simple change keeps rising.
Team pain shows up long before the app fully breaks. Merge conflicts increase because several people edit the same shared files. Reviews slow down because reviewers need context from parts of the app they do not own. Releases feel tense because one harmless-looking change in a common package can affect account pages, reports, and admin screens at once.
That is why teams usually decide to split a large React app. The pressure comes from ownership and release habits, not file count. A messy app with 40 screens can hurt more than a well-structured app with 400.
The warning sign is simple. If one team cannot ship a small change without waiting on three other teams, the app is already too tangled. Size makes that pain louder, but weak boundaries cause it first.
Why microfrontends are often the wrong first move
Microfrontends can solve a real problem, but they also create a new one. You do not just split code. You split runtime behavior, deployment rules, shared UI, and part of the team's attention.
A normal React split usually means packages, route boundaries, and cleaner ownership inside one app. That keeps one router, one design system, one auth flow, and one release path. A microfrontend setup adds extra layers on top: app shells, remote loading, version matching, cross-app state rules, and more ways for one team to break another team's screen.
Routing often gets harder first. A single app can keep route ownership clear with simple rules like "billing owns /billing" and "reports owns /reports." With microfrontends, teams start arguing about navigation, redirects, layout, and who controls shared areas like headers or account menus.
State gets messy soon after. If a user signs in, changes an organization, or updates settings, every part of the app needs the same answer. Inside one deployable app, that is boring and predictable. Across multiple runtimes, shared state often turns into custom events, duplicated caches, or awkward reloads.
Design drift is the quieter cost. One button becomes three versions. Form errors look different from page to page. Small differences pile up, and the product starts to feel stitched together.
One deployable app still makes sense when teams work on different routes but share the same users, navigation, auth, design system, and data rules. It also works well when releases happen often and teams can still coordinate in one pipeline. In that setup, the real problem is ownership, not browser-level isolation.
If you need to split a large React app, start by splitting responsibility, not runtimes. Put code into packages with clear boundaries, assign route owners, and make releases predictable. If that still does not reduce team friction, then microfrontends may be worth the extra cost.
Draw package boundaries around real work
A clean split starts with how people work, not how folders look. If one team owns billing, give billing its own package. Put the screens, API calls, form rules, and tests for billing there. A new developer should open the folder and see a full slice of the product, not a scattered mix of components, hooks, and utils.
Grouping by file type feels tidy for a month or two. Then every feature touches six folders, and simple changes turn into scavenger hunts. Product-area packages cut that noise. They also make code reviews easier, because most changes stay inside one area.
When one team owns part of the app, keep its logic close together. UI code often depends on request shapes, validation rules, and small business rules. Splitting those into separate shared layers too early usually creates more hopping around and more arguments about where code belongs.
A billing package, for example, can include invoice screens, payment method pages, queries that call billing endpoints, formatting rules used only by billing, and tests for billing flows. That is a complete slice of work, and it is much easier to reason about.
Move code out only when two areas truly use it and use it the same way. "We might need this later" is a bad reason to create a common package. Most early shared folders become junk drawers. Wait for repeated use, then extract the smallest thing that removes real duplication.
Package names should be boring and obvious. @app/billing, @app/account, and @app/reports work better than vague names like @app/core or @app/common. If the name hides ownership, people will keep dropping random code into it.
This is where the payoff starts. Good package boundaries cut review noise, reduce accidental coupling, and make later ownership rules much easier to keep.
Give each route a clear owner
Large React apps get messy when many people can change the same route and nobody feels responsible for the result. A route needs an owner the same way a product area needs an owner.
Give every main route one team or one person who can say yes, no, or not now. That owner does not need to build every change alone. They do need the final call on how that route works, what belongs inside it, and what should move to a shared package.
This works best when the owner controls local decisions. They should choose the local components, local state shape, data loading flow, and route-level tests. If another team wants to reuse something, the default answer should stay simple: move it into a shared package only if more than one route truly needs it.
Write the boundary down in a short rule set. Other routes should import public pieces only. Public pieces should live in a clear export file. Private hooks, helpers, and components should stay inside the route. If two routes need shared state, create an agreed shared package instead of reaching into each other's files.
That last part matters more than it seems. Teams usually break boundaries by importing another route's internals because it feels faster. A quick import from another route can create months of hidden coupling. Then one small refactor in /settings breaks /checkout for no good reason.
Ownership also keeps orphan routes from rotting. Old admin screens, migration pages, and forgotten report views often survive long after the original team moves on. Review those routes on a schedule. If nobody owns one, assign it, merge it into another area, or remove it.
A small doc is enough. List each route, its owner, and its public exports. Keep it current. When a route breaks in production, everyone should know who makes the call and who can fix it the same day.
Split the app in small steps
Big rewrites usually fail because teams change structure, ownership, and release habits at the same time. A better move is to map the app as it exists today. Write down every route, who changes it most often, what code each route shares, and where releases slow down because one change spreads across too many files.
Start with one route that feels crowded and busy, not the hardest one. Maybe billing changes every week while reports barely move. Move billing into its own package first and keep the app running the whole time. That first move teaches the team more than two weeks of planning.
Start with one route
Keep the boundary thin and obvious. Put that route's screens, hooks, tests, and local components inside the package. Leave truly shared UI in a shared package, but stay strict. If a helper only exists for billing, keep it in billing.
Then block new imports between routes. Old imports can stay for a short time while you clean them up, but new ones create fresh debt. A lint rule or import check in CI is enough. The goal is simple: while the team fixes old boundary leaks, nobody adds new ones.
Add small APIs between packages
Teams create a mess when one route reaches deep into another route's files. Do not allow that. Expose a small API from each package, such as a component, a hook, or a typed function. Account can ask billing for "get billing summary." Account should not import five internal billing files just because it works today.
A simple pattern works well: one package for one route or feature, one public entry file per package, one owner for changes that cross the boundary, and one short release note when a package changes its public API.
After the first route, repeat the same process with the next busiest area. Do not wait for a perfect design. By the time you move the third route, the team usually sees which shared code is real and which code was only shared because it was easy to grab.
Use release discipline to keep boundaries intact
Teams usually break package boundaries during busy weeks, not because the design was bad. A shared package changes quickly, nobody writes down what moved, and another team updates it on Friday to unblock a feature. Two routes break, and people start importing around the boundary "just for now."
If you want to keep the split healthy, treat package updates like small product releases. Give each shared package a version number that means something. Add short change notes for every release: what changed, which routes might feel it, and whether the update is safe, risky, or breaking.
Risky refactors need their own window. Do not merge a rename of common hooks, route contracts, or design tokens a day before a launch. Ship customer-facing work first. Move deeper cleanup into a separate batch when teams have time to test and fix fallout without panic.
One group should approve any change that crosses a boundary. That can be a platform team, an architecture owner, or one senior engineer rotating each week. The rule matters more than the title. If account wants to reach into billing internals, someone should stop it before it lands.
The release routine itself can stay simple. Publish shared packages with a clear version and short notes. Test the affected routes before the update, apply the change, and test those same routes again after it. Keep rollback steps in the repo and short enough to follow under stress.
Written rollback steps save time when something slips through. "Revert package X to 2.3.1, redeploy route Y, clear cached bundle" is much better than a vague plan in chat.
This sounds strict, but it is cheaper than slow drift. A team can live with one extra approval and a few route checks. It cannot move fast for long if every release quietly punches holes through the boundaries it worked to create.
A simple example with account, billing, and reports
Picture a React codebase with one big src folder. Account screens, billing forms, report widgets, hooks, API calls, and shared UI all sit side by side. That setup works for a while, then every change starts to feel like touching a pile of loose wires.
A better split starts where ownership is already obvious. Move the account area into its own package first. Profile pages, password changes, team members, and account settings usually belong together, and one person or team can review every change there.
That package should not swallow everything it touches. Keep shared buttons, form fields, and basic layout pieces in a separate shared package. The account package should hold account work, not become a second junk drawer.
Billing often comes next because mistakes there cost real money and real trust. Give billing its own route area and its own package, then move checkout, invoices, payment methods, and billing settings behind that boundary. If a change affects charges, the billing owner reviews it. That rule sounds strict because it should be.
Reports are the area many teams move too early. Leave them where they are if the code still leans on shared filters, tables, and export logic from other parts of the app. Wait until you see real duplication or a clear need for separate release timing. Until then, forcing a reports package can add more friction than order.
Once account and billing have clear owners, releases usually get calmer quickly. A profile update does not pull in billing review. A payment change does not accidentally ship with report tweaks. People know who approves what, what tests matter, and which package they can change without starting a long cross-team thread.
That is the part teams often miss. React package boundaries help, but the bigger win is clear ownership. When account, billing, and reports stop feeling shared by everyone, they also stop breaking for everyone.
Mistakes that turn the split into a new mess
Teams usually break a big React app in the right place, then slowly ruin the split with small exceptions. One import here, one rushed package there, and six months later nobody can tell what belongs to whom.
A common mistake is the fake shared package. A route has one helper, one hook, or one form component, and someone moves it into shared because "we might reuse it later." If only billing uses that code, keep it in billing. A shared package should solve a repeated problem. Otherwise it turns into a junk drawer that every team touches and nobody owns.
The next problem is routes reaching into each other's internals. Reports imports a billing hook. Account reads billing state directly. It feels faster in the moment, but it creates hidden ties between teams. Then a small refactor in one route breaks another route that should have stayed separate. If a route needs something from another route, expose a small public API and stop there.
Changing boundaries every sprint creates a different mess. Teams never get used to the structure, tests keep moving, and ownership stays fuzzy. Boundaries do not need to be perfect on day one, but they do need some stability. Move code only when the current split creates real pain, not because a new folder idea looks cleaner.
Release rules matter too. If one team ships every day and another waits for a manual release, shared changes pile up and block each other. That friction can look like a code problem when the schedule caused it. Pick one simple release rhythm and one versioning rule for everybody.
Starting module federation before basic ownership works is often the most expensive mistake. Extra runtime layers do not fix blurry package boundaries. They just make broken boundaries harder to see and harder to debug.
A healthy split usually looks boring. Each route owns its own code. Shared packages stay small. Routes import public APIs, not internals. Teams follow the same release rules. Runtime stays simple until the team proves it needs more.
Quick checks before you call it done
A split app can look clean on a whiteboard and still feel messy every week. When the structure is working, most work stays local. One team changes one area, ships it, and can roll it back without dragging three other teams into the same release.
You do not need a huge audit to check this. Start with a few direct questions:
- Does every route have one clear owner?
- Does each package expose a small public entry point?
- Is shared code still rare, or is
sharedgrowing every week? - Can teams ship route changes without touching unrelated routes?
- Can someone follow the rollback steps without a long call?
One practical test works better than most architecture debates. Open the last five pull requests and see what changed. If work on reports keeps touching billing internals, or account work keeps editing shared code, your packages are probably split by folders, not by responsibility.
It also helps to watch how people ask for help. If developers still say, "I need to check who owns this" after opening a route, ownership is blurry. If they know the owner, know the package entry point, and know how to undo the change, the split is doing its job.
What to do next
If you want to split a large React app, start with a map, not a rewrite. Most teams already know where the pain is. They feel it in slow reviews, unclear ownership, and releases that touch too many files.
This week, open your route list and write down one owner for each area. If nobody owns a route, that is usually the first problem to fix. Ownership makes package boundaries easier because you can group code around real work instead of abstract folders.
Then pick one route with clear business value for the first split. Write two or three import rules and enforce them in linting and code review. Check how you release changes now before you add runtime layers that make existing problems harder to see.
Keep the first split boring. Choose a route like billing, reports, or account settings. Move its screens, state, tests, and local helpers into one package. Then watch what breaks. You will learn more from one careful split than from a month of architecture debates.
Release habits need the same attention. If one change still requires three teams to approve it, your structure is not doing enough yet. Tighten versioning, review who can change shared packages, and make route owners responsible for shipping their area.
If you want a second opinion, Oleg Sotnikov at oleg.is works as a Fractional CTO with startups and smaller teams on package boundaries, route ownership, release flow, and practical AI-first engineering setups. That kind of outside review can help before you add more runtime complexity.