How would you structure a scalable frontend app with 100+ pages?
Feature-based folder structure, shared packages for design system / utils, route-level code splitting, owned layouts per area, contracts at module boundaries.
Once a frontend grows past 100 pages it almost always also has 10+ teams committing to it. The architecture stops being a technical problem and becomes a change-isolation problem: how do I let team A ship without breaking team B, without forcing a global rebuild, and without waiting for a slow CI? The answer is a set of conventions that minimize coupling at every layer.
1. Folder structure: feature-first, not type-first.
apps/
web/
app/ # Next.js App Router — thin route shells
(marketing)/
(app)/
(admin)/
features/
checkout/ # everything checkout: components, hooks,
# server actions, tests, schemas
catalog/
account/
shared/ # cross-feature primitives ONLY
packages/
ui/ # design system, headless + styled
config/ # eslint, tsconfig, tailwind preset
lib/ # cross-app utilities (date, money, http)
types/ # shared TS types / Zod schemasA page in app/ is thin — it just composes feature components. The feature folder owns its UI, hooks, data fetchers, server actions, and tests. Imports between feature folders are forbidden — enforce with eslint-plugin-boundaries, eslint-plugin-import, or Nx tags. Cross-feature communication goes through a shared package with a stable contract (events, a shared store, or URL params). The "boring rule" — checkout cannot reach into catalog — is what lets the two teams ship independently.
2. Routing & layouts. Next.js App Router with route groups ((marketing), (app), (admin)) gives each major area its own layout and middleware. Parallel routes (@modal) and intercepting routes handle slot composition. Each leaf route should be a few lines: parse params, render a feature component.
3. Code splitting. Route-level splitting is automatic. For very heavy widgets (charts, code editors, maps) use next/dynamic({ ssr: false }). Aggressively tree-shake the design system: keep components as separate entry points (@ui/button, not @ui) and avoid barrel files that defeat tree shaking. Set a bundle-size budget per route in CI.
4. State strategy. Server state goes through TanStack Query or RSC + Server Actions. Client state lives inside the feature (useState, useReducer, a feature-scoped Zustand store). Global state is reserved for truly cross-cutting concerns: theme, auth user, feature flags, toast queue. Reach for Context only when (a) prop-drilling exceeds three levels and (b) the value is genuinely shared. A global store is the most common architectural smell — it turns every feature into a transitive dependency of every other.
5. Data contracts. Every API boundary should be typed and validated. Use Zod schemas at the edge: parse the response, throw on schema drift, types flow inward for free. Generate types from OpenAPI / GraphQL where you don't own the schema. A shared packages/types package is the single source of truth.
6. Design system. A separate package consumed as a peer dep. Components are headless + styled (Radix / Headless UI primitives + your tokens) so accessibility is non-negotiable. Versioned with changesets so a breaking change is intentional and surfaced in CI.
7. Build & CI. Turborepo or Nx for incremental builds, with remote cache. CI runs only the affected packages on each PR. Preview deployments per PR. Type-check, lint, and unit tests in parallel; e2e only against changed routes.
8. Ownership & guardrails. CODEOWNERS at the feature folder; dashboards per feature; an error-budget / SLO per route. The platform team owns packages/; product teams own a feature folder end-to-end. Architecture decision records (ADRs) checked into the repo so the why* is preserved.
9. Observability. Real-user metrics (LCP / INP / CLS) tagged per route. Source-mapped errors via Sentry / Bugsnag grouped by feature. A weekly perf budget review is cheaper than emergency fixes.
The thread connecting all of this is "make the smallest reasonable change cheap and the wrong change loud." Conventions that the linter or codeowners can enforce are worth more than any clever runtime architecture.
Code
Follow-up questions
- •When would you reach for module federation / micro-frontends?
- •How do you keep the design system in sync across teams?
- •What's your strategy for shared global state?
Common mistakes
- •Type-first folder structure (`/components`, `/hooks`, `/utils`) — scales poorly past 50 components.
- •Putting routing logic into feature files — keeps composition entangled.
- •One global Redux store for everything.
Performance considerations
- •Bundle splits per route + lazy heavy widgets.
- •Watch for design-system regressions — one prop change can rebuild every page.
Edge cases
- •Migrations are the real test — feature isolation lets you migrate one slice at a time.
Real-world examples
- •Shopify Admin, Vercel dashboard, Linear — all use feature-based + shared package patterns.