Web Development
ReactNext.jsFrontend ArchitectureScalabilityCode OrganizationTesting

Modern React Frontend Architecture: Feature-Based Modules, Boundaries, and Scalability

AO
Adrijan Omićević
·13 min read

# What You’ll Build in This Guide#

You’ll implement a React frontend architecture feature-based folder strategy that scales past the first 3 to 5 features without turning into a maze of shared utilities and circular imports.

You’ll learn where to place API clients, UI primitives, domain logic, tests, and cross-cutting concerns in both React and Next.js. You’ll also get dependency rules you can enforce with ESLint, plus examples you can copy into your codebase.

This structure is designed for teams where:

  • The app will grow beyond one developer.
  • Multiple features ship in parallel.
  • You want refactors to be localized instead of “touch 40 files across 10 folders”.

# Why Feature-Based Architecture Wins at Scale#

Folder structure becomes architecture the moment you have:

  • more than 10 screens,
  • more than 2 developers,
  • more than 1 integration,
  • more than 1 release per week.

A feature-based approach reduces coordination cost because code changes tend to stay within a module that matches how the product is discussed: onboarding, billing, search, checkout, account settings.

The industry trend backs this up. The 2024 State of JS survey reports React remains the most used UI library, and the most common pain reported by React teams is maintainability and complexity as apps grow. Feature-based boundaries don’t eliminate complexity, but they contain it.

If you also care about consistent UI primitives and scalable component design, pair this guide with React component architecture for scalable design systems.

# Core Principles: Boundaries, Dependencies, and Public APIs#

A feature-based architecture works only if boundaries are real. That means you define:

  1. 1
    What each module owns
  2. 2
    What it is allowed to import
  3. 3
    What it exposes publicly

Principle 1: Modules own business capability, not just UI#

A feature module should include:

  • route-level UI (pages, screens)
  • feature state and orchestration
  • domain rules for that feature
  • feature API calls
  • feature-specific components
  • feature tests

If you keep domain logic in random shared utilities, you recreate a monolith, just with more folders.

Principle 2: Dependencies flow one way#

Your structure should prevent a “grab bag” shared layer that everything depends on and everything mutates.

A simple rule that scales well:

  • shared depends on nothing
  • entities depends on shared
  • features depends on entities and shared
  • app composes features and global providers

This is close to well-known patterns like feature-sliced design, but simplified so it stays pragmatic.

Principle 3: Every module exports a public API#

If other modules import deep paths, boundaries are imaginary. You want imports like:

TypeScript
import { CheckoutPage } from "@/features/checkout";
import { formatMoney } from "@/shared/lib/money";

and not:

TypeScript
import { formatMoney } from "@/shared/lib/money/formatMoney";
import { CheckoutForm } from "@/features/checkout/ui/forms/CheckoutForm";

Deep imports bypass decisions and create “spaghetti coupling”.

🎯 Key Takeaway: A folder structure is only architecture if you enforce dependency direction and public exports.

This structure supports both:

  • React SPA with React Router
  • Next.js App Router with React Server Components

Use a src root and keep Next.js app at the project root.

High-level layout#

LayerResponsibilityCan importCannot import
appRoute composition, providers, bootstrappingfeatures, entities, sharedfeature internals
featuresUser-facing capabilities, orchestrationentities, sharedother feature internals
entitiesCore domain models and reusable entity UIsharedfeatures, app
sharedUI primitives, libs, config, base API clientnothing (or only npm deps)app, features, entities
testsCross-feature test utilities, fixturessharedfeature internals (ideally)

Example folder tree#

Bash
src/
  app/
    providers/
    routes/
  features/
    auth/
    checkout/
    search/
  entities/
    user/
    product/
    order/
  shared/
    api/
    ui/
    lib/
    config/
    types/
  tests/
    fixtures/
    helpers/

For Next.js App Router, you will typically have:

Bash
app/
  (marketing)/
  (app)/
  api/
src/
  features/
  entities/
  shared/

The Next.js app directory is framework-owned routing. Your actual product code lives in feature modules under src.

ℹ️ Note: In Next.js, avoid putting business logic directly in app routes. Treat route files as composition glue and keep logic in src/features so it remains portable and testable.

# How to Design Feature Modules#

A feature module should feel like a small package with its own public surface.

Feature module template#

Bash
src/features/checkout/
  index.ts
  model/
    useCheckout.ts
    checkoutMachine.ts
    selectors.ts
  api/
    checkoutApi.ts
    types.ts
  ui/
    CheckoutPage.tsx
    CheckoutForm.tsx
    components/
      PaymentMethodPicker.tsx
  lib/
    pricing.ts
    validation.ts
  tests/
    checkout.test.ts
    pricing.test.ts

What goes where#

FolderPut hereExample
apiFeature-specific API calls and DTO mappingcreateOrder, applyCoupon
modelState, hooks, orchestratorsuseCheckout, XState machines
uiScreens and feature componentsCheckoutPage, CheckoutForm
libPure functions used inside the featurevalidateAddress, calcTotals
testsFeature tests close to codepricing.test.ts
index.tsPublic exports onlyexport { CheckoutPage }

Feature public API example#

TypeScript
// src/features/checkout/index.ts
export { CheckoutPage } from "./ui/CheckoutPage";
export { useCheckout } from "./model/useCheckout";
export type { CheckoutDraft } from "./model/types";

Only export what other modules should depend on. Everything else stays internal.

⚠️ Warning: If features import other features directly, you’ll quickly get cycles like checkout -> auth -> cart -> checkout. Prefer cross-feature communication through shared abstractions, or lift orchestration to app.

# Where API Clients Belong (Without Creating a Global God Module)#

Most teams start with src/api and end up with 200 files and unclear ownership. Instead, split “transport” from “endpoints”.

Shared API transport#

Keep a minimal HTTP client in shared/api. It should be boring and stable.

Bash
src/shared/api/
  httpClient.ts
  errors.ts
  authHeader.ts
TypeScript
// src/shared/api/httpClient.ts
export async function http<T>(
  input: string,
  init?: RequestInit
): Promise<T> {
  const res = await fetch(input, {
    ...init,
    headers: {
      "content-type": "application/json",
      ...(init?.headers ?? {}),
    },
  });
 
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }
 
  return (await res.json()) as T;
}

Feature endpoints and mapping#

Endpoints and DTO mapping belong to the feature because they change with feature requirements.

TypeScript
// src/features/checkout/api/checkoutApi.ts
import { http } from "@/shared/api/httpClient";
 
export type CreateOrderInput = {
  items: Array<{ productId: string; qty: number }>;
};
 
export type CreateOrderResponse = { orderId: string };
 
export function createOrder(input: CreateOrderInput) {
  return http<CreateOrderResponse>("/api/orders", {
    method: "POST",
    body: JSON.stringify(input),
  });
}

This avoids a central api folder becoming a bottleneck and keeps API changes localized.

Next.js note: server-only API calls#

If you are using App Router and want server-side fetching, move server-only API modules into a server folder and avoid client imports.

Bash
src/features/orders/
  server/
    ordersService.ts
  api/
    types.ts

Then call ordersService from server components, route handlers, or server actions.

For deeper patterns around RSC, see React Server Components guide.

# UI Layers: Primitives vs Feature UI vs Entity UI#

Your UI layer should prevent two common problems:

  1. 1
    Copy-pasted UI primitives across features
  2. 2
    A “components” folder that turns into a dumping ground

Shared UI primitives#

These are low-level building blocks: Button, Input, Modal, Tabs, Skeleton, Toast. They must be:

  • styling-consistent
  • accessible by default
  • dependency-light
Bash
src/shared/ui/
  button/
    Button.tsx
    Button.test.tsx
    index.ts
  input/
  modal/
  index.ts

A good rule: if a component has business meaning like CheckoutSummary, it is not shared UI.

Entity UI#

Entity modules are reusable because they represent core domain concepts: user, product, order. They often include “entity widgets” like UserAvatar, ProductCard, OrderStatusBadge.

Bash
src/entities/product/
  model/
    types.ts
  ui/
    ProductCard.tsx
    PriceTag.tsx
  index.ts

Entity UI is the glue that keeps features consistent without pushing everything into shared.

Feature UI#

Feature UI composes shared UI and entity UI into outcomes: forms, screens, flows.

Bash
src/features/search/ui/
  SearchBar.tsx
  SearchResults.tsx
  SearchPage.tsx

💡 Tip: Run a design review when introducing new shared UI primitives. Shared components are expensive to change later because they accumulate many consumers. Use a checklist like React design review checklist for performance, accessibility, maintainability.

# Domain Logic: Keep It Pure, Close to the Feature, and Testable#

When domain rules are scattered across UI components, you get:

  • duplicated logic
  • fragile changes
  • hard-to-test behavior

Where domain logic should live#

  • Feature-specific domain rules stay inside features/<feature>/lib or features/<feature>/model.
  • Cross-feature, stable domain primitives can go under entities/<entity>/model.
  • Truly generic utilities go under shared/lib.

Example: pricing logic in checkout#

TypeScript
// src/features/checkout/lib/pricing.ts
export function calcTotals(
  subtotalCents: number,
  discountCents: number,
  taxRate: number
) {
  const discounted = Math.max(0, subtotalCents - discountCents);
  const taxCents = Math.round(discounted * taxRate);
  return {
    subtotalCents,
    discountCents,
    taxCents,
    totalCents: discounted + taxCents,
  };
}

This can be unit-tested without rendering React, which keeps tests fast and reliable.

# Next.js Specifics: App Router, RSC, and Client Boundaries#

Next.js adds constraints that actually help architecture, if you lean into them.

Route files as composition only#

Keep app routes thin:

  • read params
  • call feature entry components
  • wire providers
TypeScript
// app/(app)/checkout/page.tsx
import { CheckoutPage } from "@/features/checkout";
 
export default function Page() {
  return <CheckoutPage />;
}

Avoid placing API calls, validation rules, and transformation code in the route file.

Separating client and server code#

A practical convention:

FolderRuns whereTypical content
features/x/uiclient by defaultinteractive components
features/x/serverserver onlyDB-backed services, secret API calls
features/x/apieitherDTO types, shared mapping

If you need a client component, mark it explicitly at the top of the file.

TypeScript
// src/features/checkout/ui/CheckoutForm.tsx
"use client";
 
import { useCheckout } from "../model/useCheckout";
 
export function CheckoutForm() {
  const { submit, isLoading } = useCheckout();
  return (
    <button onClick={submit} disabled={isLoading}>
      Place order
    </button>
  );
}

This keeps the server and client boundary explicit. It prevents accidental bundling of server-only dependencies into the browser.

# Dependency Rules You Can Enforce#

Rules prevent regressions when the codebase grows and new developers join.

Practical dependency rules#

  1. 1
    shared imports nothing from your app code.
  2. 2
    entities can import from shared only.
  3. 3
    features can import from entities and shared.
  4. 4
    app can import from everything, but other layers should not import from app.
  5. 5
    Features should not import internals of other features. Only public exports.

ESLint rule example (import restrictions)#

This is a minimal example using ESLint core rules. Many teams also add eslint-plugin-boundaries, but even core rules are a good start.

JavaScript
// .eslintrc.cjs
module.exports = {
  rules: {
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["@/features/*/*"],
            message: "Import from a feature public API only, not internal paths.",
          },
          {
            group: ["@/app/*"],
            message: "Do not import from app layer; app is composition only.",
          },
        ],
      },
    ],
  },
};

This prevents deep imports like @/features/checkout/ui/CheckoutForm from other modules, pushing developers to export from @/features/checkout.

# Testing Strategy: What to Test and Where to Put It#

A scalable testing setup avoids over-reliance on slow UI tests.

Test typeLocationWhat it coversFast?
Unit testsfeatures/*/lib or entities/*/modelpure logic and domain rulesYes
Component testsshared/ui or features/*/uicomponent behavior, a11y, statesMedium
Integration testsfeatures/*/testsfeature flows with mocked APIMedium
E2E testse2e/real user journeysNo

Example: unit test for pricing#

TypeScript
// src/features/checkout/lib/pricing.test.ts
import { calcTotals } from "./pricing";
 
test("calcTotals computes total with discount and tax", () => {
  const r = calcTotals(10000, 2000, 0.25);
  expect(r.totalCents).toBe(10000);
  expect(r.taxCents).toBe(2000);
});

Keep unit tests close to the logic. When you refactor, tests move with the code and remain discoverable.

# Common Scalability Pitfalls and How to Avoid Them#

Pitfall 1: The shared folder becomes a junk drawer#

If shared includes feature-specific code, everything starts depending on it and refactors become risky.

Fix: shared contains only primitives and truly generic libs. Move business meaning into entities or features.

Pitfall 2: “components” as the only organizing concept#

A top-level components folder hides business context. You end up searching for “where is the checkout form” and guessing.

Fix: feature modules own their UI. Shared UI is only primitives.

Pitfall 3: Deep imports that bypass boundaries#

Deep imports are a leading indicator of future coupling.

Fix: enforce public APIs via index.ts files and lint rules.

Pitfall 4: Duplicated API mapping logic#

When every screen maps the same DTO differently, bugs appear during API changes.

Fix: map DTOs once inside feature api modules or entity model mappers.

# A Maintainable Example: Putting It All Together#

Assume you’re building an e-commerce app with search, checkout, and account.

  • shared/ui provides Button, Input, Dialog.
  • entities/product provides ProductCard, types, and formatting.
  • features/search owns search query state, debouncing, search API, and results UI.
  • features/checkout owns pricing logic, cart submission, and payment flow.
  • app wires routing, providers, and feature composition.

This keeps changes local:

  • Changing pricing rules affects features/checkout/lib/pricing.ts and tests.
  • Changing how a product is displayed affects entities/product/ui/ProductCard.tsx.
  • Changing Button styling affects shared/ui/button.

When you do an architectural review, you can scan feature boundaries quickly, then apply a checklist like React design review checklist for performance, accessibility, maintainability.

# Key Takeaways#

  • Organize by features and entities, not by generic “components”, and keep route files as thin composition.
  • Put a thin HTTP transport in shared/api, and keep endpoints and DTO mapping inside each feature.
  • Separate UI into shared primitives, entity UI, and feature UI to avoid a bloated shared layer.
  • Enforce one-way dependencies and feature public APIs using index.ts exports and ESLint import restrictions.
  • Keep domain logic pure and test it with fast unit tests close to the code, then add integration and E2E only where needed.

# Conclusion#

A React frontend architecture feature-based setup succeeds when boundaries are enforceable, imports are intentional, and business logic stays close to the features that own it. If you want help applying this structure to an existing codebase, or you need a Next.js App Router architecture that’s ready for RSC, accessibility, and performance reviews, reach out to Samioda for an architecture audit and a concrete refactor plan.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.