Frontend architecture: modular applications
Organize by feature domains, not by file type. Each module owns its UI, state, API calls, and types — and exposes a small public API. Enforce boundaries with lint rules (import restrictions), shared primitives only via a `core` package, and avoid cross-feature imports. Use TS path aliases, codeowners, and dependency-cruiser to keep the graph from collapsing.
Modularity is about explicit boundaries. Without them, a 6-month-old codebase becomes a ball of string where every change touches half the files.
The two organizations you'll meet.
By file type (the wrong default).
src/
components/ # 200+ components
hooks/ # 80+ hooks
utils/ # everything not categorized
api/ # all endpoints
store/ # all reducersFinding "the cart" requires opening 5 folders. Two teams touching the cart and the catalog step on each other constantly. The folder names don't survive the product growing.
By feature (the scalable default).
src/
features/
cart/
ui/ # CartButton, CartDrawer
api/ # endpoints owned by cart
model/ # state, selectors, actions
lib/ # cart-specific utils
index.ts # public API — what the rest of the app can import
catalog/
...
checkout/
...
shared/
ui/ # design system primitives
api/ # http client, auth interceptors
lib/ # generic utils (date, formatting)
pages/ # route components, compose features
app/ # providers, router, global initEach feature is a near-package: owns its UI, data, types. The index.ts re-exports the public surface. The cart team owns features/cart/ end-to-end.
This is the Feature-Sliced Design (FSD) convention; you'll also see it called vertical slices, bounded contexts (DDD borrow), or just domain modules. The naming varies; the principle is the same.
The dependency rule.
A strict layering:
app → pages → features → sharedsharedknows nothing about anyone.featuresuseshared.pagescomposefeaturesandshared.appwires everything.
Features must not import each other. Cart can't import { CatalogCard } from "../catalog". If they need to share something, hoist it to shared or make it explicit through props at the page level.
Enforce with tooling, not vibes.
- ESLint
import/no-restricted-paths— rules per folder. dependency-cruiser— validate the dep graph in CI; fail the build on disallowed edges.- TS path aliases (
@features/cart→ src/features/cart) — make the boundary visible at the import line. - CODEOWNERS — each feature's folder has a team. PRs touching it ping that team.
The public-API discipline.
// features/cart/index.ts
export { CartButton, CartDrawer } from "./ui";
export { useCart, useAddToCart } from "./model";
export type { CartItem } from "./model";Nothing else from cart is importable. Files inside cart can change freely (refactor, rename, restructure) without anyone outside caring. This is the killer feature — internal changes don't break consumers.
State management at the module boundary.
- Per-feature state lives inside the feature (
features/cart/model/store.ts). - Cross-feature state is rare. When it happens, lift to
shared/stateor make features communicate via events / props. - Avoid a single global store with reducers from every feature — that defeats modularity.
If you use Redux, that means slice files in each feature, registered to a store in app/. With Zustand, separate stores per feature.
Monorepo vs single-package.
A modular single-package codebase is fine up to ~50 engineers. Past that, consider a monorepo (Nx / Turborepo / pnpm workspaces) with each feature as a package. Benefits: enforced boundaries (TS project refs prevent the import), parallel CI, separate ownership. Costs: tooling complexity, slower local DX if poorly configured.
Micro-frontends are a different question. Modular monolith = one deploy, fast iteration. Micro-frontends = independent deploys, independent teams, but cross-app coupling costs (versioning, shared state, navigation) are real. Default to modular monolith unless you have a clear org reason for independent deploys (e.g., teams shipping on different cadences with little overlap).
Anti-patterns that creep in.
utils/index.tswith 200 unrelated functions. Break by feature or kill.- Importing across feature internals (
features/cart/ui/Button). Hoist to shared or expose via cart's public API. - God reducers / context that every feature has to know about.
- Naming features by tech (
hooks,api) instead of by domain (cart,auth). Tech-named folders inside a feature are fine; top-level should be domain. - One feature pulling in another's API client directly. Each feature should call its own endpoints; rare cases of overlap go through a shared API client with namespaced methods.
Senior framing. The staff-engineer answer is: modularity is enforced by a dependency graph you can describe in one slide, automated by tooling so it survives the next 18 months of contributors. The candidate who says "we organize by feature" is mid. The one who can name the tooling (dependency-cruiser, ESLint path rules, CODEOWNERS), the public-API discipline, and the trade-off vs micro-frontends is senior/staff.
Follow-up questions
- •How do you enforce feature isolation in CI?
- •When is a modular monolith better than micro-frontends?
- •How do features share state without coupling?
- •What's the role of `shared` and how do you keep it from becoming a dumping ground?
Common mistakes
- •Organizing by file type instead of feature.
- •Letting features import each other freely.
- •Putting unrelated utilities in a global `utils` folder.
- •Centralizing state for cross-feature data that should be local.
Performance considerations
- •Per-feature code splitting is natural — features are split-points.
- •Monorepo tooling can speed up CI via affected-package builds.
Edge cases
- •Cross-cutting concerns like analytics — define a `shared/analytics` API; features call it.
- •Auth state — usually `shared`, accessed by everyone, but with a clean interface.
- •Cyclic imports — usually a sign two 'features' are actually one.
Real-world examples
- •Feature-Sliced Design (FSD) — community convention with public docs.
- •Linear, Vercel dashboard, GitHub's web UI — all use feature-domain organization.