Jul 29, 202610 min read

How to architect a large frontend application

The whole game is one question: can I delete a feature and have the blast radius be zero? Six boring rules, and a machine that guards them.


How to architect a large frontend application

The best compliment a codebase ever got from me was an accident.

I deleted a whole feature by hand one afternoon. Ripped out the folder, removed one line in the app, ran the build. It passed. No red anywhere. No mystery import three directories away quietly reaching into the thing I just killed. The feature was gone and nothing else even noticed.

That is the whole game. Not clever abstractions. Not the fanciest state library. The thing I actually chase in a large frontend is this: can I remove a piece and have the blast radius be zero? If yes, the architecture is doing its job. If deleting one screen breaks a settings page nobody touched in months, the architecture is a lie you tell yourself in standup.

I want to walk through how I lay out a big frontend so that stays true. I'll keep it concrete, because abstract advice about "modularity" is worth nothing and I'm tired of reading it.

Micro frontends without the micro frontend tax

People hear "micro frontend" and picture separately deployed apps stitched together at runtime with module federation, three build pipelines, and a Slack channel dedicated to why the header is a different version on the profile page. I don't want that. Almost nobody needs that. That's an org-chart solution to a code problem.

What I actually want is the feeling of micro frontends inside one repo. Small, single-purpose packages. Strict, one-directional dependencies. A hard rule that features can't reach into each other. You get the isolation without paying the runtime-integration tax.

The shape looks like this:

apps/web # the ONLY place features meet
packages/
design-system # UI primitives + tokens (knows nothing about the domain)
contracts # shared schemas — the single source of truth
auth # cross-cutting session state
utils # pure helpers
observability # logger
features/
feature-a # one vertical slice of the product
feature-b # another slice

Every arrow points one way. Features depend on the shared packages. Features never depend on each other. The app on top is the only layer allowed to know that more than one feature exists.

Here's the part that took me years to accept: this only works if a machine enforces it. Human discipline decays. Someone is tired on a Friday, they need a function that happens to live in another feature, and they just import it. It works. Tests pass. And you've quietly welded two features together forever. Six months later you can't delete either one.

So I don't rely on discipline. I wire up an import-boundary linter (eslint-plugin-boundaries is the one I reach for). If a feature imports another feature, the lint step fails. Not a warning. A failed build. The wall is real or it isn't there, and a wall you can walk through on a bad day was never a wall.

That's the difference between architecture and a diagram in a wiki. One of them says no when you're wrong.

Features aren't the smallest unit. Go one level down.

Isolating features is the easy insight. Everyone gets there. The thing people skip is that the same problem shows up inside a feature the moment it gets big.

Say a feature does three things. Early on that's fine as one blob. But those three things often have nothing to do with each other. If I let them freely import from one another, I've just recreated the spaghetti I banned at the feature level, one directory deeper. The wall stopped at the front door.

So features get the same treatment internally. Two zones: common/ for what genuinely gets shared, and modules/ for the vertical slices that stay isolated.

The rule is the same rule, fractal. A module can't import a sibling module. If two of them need the same thing, it doesn't get passed sideways between them. It lifts up into common/, and both reach up for it. Never sideways.

And the whole feature exposes exactly one door: the root index.ts. The app imports the feature. It does not import feature/modules/module-1/components/SomeComponent. It can't even see that far in. When I want to rename a file, move a folder, split a module in two, I know the entire outside world is touching one export list. I can go wild behind that door.

The same linter enforces this second layer too. A module-imports-sibling mistake fails the build exactly like a cross-feature one would. The pattern gets documented, but I don't count on people reading docs. I count on the build going red.

The design system is a product, and it gets its own front door

I have a strong opinion here and I'll defend it: your design system needs its own running page, separate from the app. Not a Figma file. Not a folder of components you eyeball inside the real app. A living, rendered catalog you can open on its own.

In practice that's a tool like Storybook, running on its own port. The app boots on one port. The design system boots on another. Two different things you can look at independently, because they are two different things.

Why does this matter so much to me? A few reasons that all cost me pain before I learned them.

You cannot see a component's real state inside a feature. In the app, a button is whatever the app's current data makes it. Loading, error, disabled, the seven-word label that wraps to two lines and breaks the layout, the empty state nobody remembers to check. On its own page, you render all of those on purpose, next to each other, and you catch the broken one before a user does.

It forces the design system to be honest about what it knows. The single most important rule for the design-system package is a constraint: it knows nothing about your domain. It ships tokens and buttons and dialogs. It has never heard of your business objects. The second your "Button" needs to know about your domain, it's not a design system component, it's a feature component wearing a costume, and it'll rot the moment the domain shifts. Having the design system live on its own page makes that violation obvious, because you literally can't render a domain-flavored component in a place where the domain doesn't exist.

And it gives the rest of the app one honest source for how things look. The tokens live in one place, a color scale plus semantic variables for light and dark, projected onto your utility layer. Features consume the tokens. They don't invent colors. When design changes the brand, one package changes and everything downstream follows, because nothing downstream was allowed to hardcode a hex value in the first place.

The design system sits at the bottom of the dependency graph and points at nothing above it. Everyone uses it. It uses no one. That's exactly where a design system belongs, and giving it its own page is how you keep it honest about staying there.

One contract, shared by both sides, parsed at the door

This is the piece I'd fight hardest to keep, and it's the one that sounds boring until it saves you.

Every request and response in the app is a schema, and those schemas live in their own package (I use Zod for this). The client imports them. The backend, or your mock of it, imports them. Same schema, both sides. They physically cannot drift, because there's only one of them.

The habit that comes with it: parse at the boundary. Data crossing into your app gets validated the instant it arrives, not trusted and passed around and blown up four components later when someone reads user.name and it was undefined the whole time. A shape mismatch becomes a loud, typed, located failure right where the data entered. You stop debugging "why is this undefined" and start reading an actual error that points at the actual line.

A mocked backend is worth a note here, because it changes how you build. You can run the whole product in the browser with a tool like MSW intercepting real fetch calls, cookies and all. That means one install and one command boots the entire product with nothing else running, and every developer, and every CI run, hits the exact same "backend." The mock isn't a stub you delete later. It's a real network round trip that happens to terminate in memory, validated by the same contract the real thing would use. When the real API shows up, you swap the layer under the contract and the app above it doesn't flinch.

Keep the data library in a cage

Last opinion, and it's a specific one. Pick a server-state library, TanStack Query is my default, and then refuse to let it spread.

The instinct is to reach for useQuery wherever you need data. That instinct is how every component in your app ends up knowing about your cache, your query keys, your refetch intervals. Rip out the library later and you're editing two hundred files.

So the library is confined to a data layer. Read hooks live in common/hooks. Mutations live in the module that owns them. Everything else, every component, the entire app on top, consumes plain intent-named hooks like useThings or useDoThing. A component doesn't know a cache exists. The app composes components and has never heard of a query key.

The payoff is concrete. Think about an action that has to optimistically update the screen, then reconcile to the server's truth, then invalidate every other view that touched the same data, all coherently. That's genuinely tricky caching logic. And it lives in one place. The component just calls one hook and renders a form. It has no idea any of that reconciliation is happening. When you get the caching wrong, and you will, several times, you know exactly the one folder to open.

The intent-named hook is also just a better name. useBalance tells you what you get. useQuery(keys.balance()) tells you how the sausage is made. Components should read like what they do, not like how the data got there.

The point of all of it

None of this is about being clever. If anything it's about being boring on purpose, in a way that compounds.

Strict one-way dependencies, features that can't touch each other, the same rule applied one level down, a design system that lives on its own page and knows nothing about your domain, one contract shared by client and backend and parsed at the door, and the data library locked in a cage. Six rules. A linter that enforces the ones that matter so a tired Friday can't undo them.

Do that and you get the thing I opened with. You can delete a feature and the build stays green. You can hand a new person one module and they can understand it without reading the whole app. You can swap the mock backend for a real one, or rip out the data library, or rebrand the whole thing, and the blast radius is exactly the box you drew around it.

A big frontend doesn't get hard because it's big. It gets hard because everything can reach everything. Draw the walls, make a machine guard them, and big stops being scary. It's just a lot of small things that mind their own business.