How to buildOctober 11, 20269 min read
Frontend DevelopmentStep 18 of 18

Frontend architecture: project structure, layers and boundaries

In short

Frontend architecture is an agreement about where code lives and who may import whom. A workable basis for a growing project: group code by feature, split UI, logic and data access inside each feature, expose a feature only through its public API (index.ts) and forbid “upward” imports from shared modules into features. These rules should be checked by a linter, not kept in people’s heads: React and Next.js don’t impose a structure, so only the team maintains it.

Deep diveLevel: AdvancedFrontend Architecture

After this you will be able to

  • Propose a feature-based folder structure with a ui / model / api split and argue for it against grouping by type
  • Describe the app → features → shared layers and explain why upward imports create coupling and cycles
  • Shape a feature’s public API with index.ts and name the risks of index files
  • Enforce import rules with no-restricted-imports in ESLint flat config and verify they fire on violations

Verified with

Next.js 16.4 (App Router, create-next-app)React 19.3TypeScript 5.9ESLint 9.39 (flat config) + eslint-config-next 16.4Node.js 24 LTS (24.18)

Checked against docs: October 11, 2026

Contents
  1. 01Symptoms: when the structure stops working
  2. 02By type or by feature
  3. 03Layers: UI, state, data
  4. 04Module boundaries and public APIs
  5. 05Import rules: enforce them with a linter
  6. 06Where API access lives
  7. 07Monorepo and a shared UI kit: when it’s time

Symptoms: when the structure stops working

Let’s start with facts. React doesn’t prescribe a folder structure: the legacy React docs say plainly that React has no opinions on how you put files into folders. The Next.js docs call the framework “unopinionated” about organising project files and advise choosing a strategy that works for the team and being consistent. So architecture is the team’s decision, and the one who will enforce it is you, not the framework.

Next come observations that signal it’s time to revisit that decision. These are signs from practice, not metrics:

  • A change in one feature breaks another, and it’s found by a user rather than in review
  • Adding a form field means opening five folders: components, hooks, services, types, utils
  • utils and common hold everything, and nobody dares delete anything there
  • Imports like ../../../../components/... and circular dependencies between modules
  • A UI component calls fetch itself, parses the server response and holds global state
  • A new developer can’t tell where to put a new file without asking in chat

By type or by feature

The legacy React docs describe two common approaches: grouping by feature or route and grouping by file type. Next.js, in its project organisation section, lists three strategies: keep code outside app, keep it in shared folders inside app, or split it by feature and route, leaving only shared code at the root. Next.js lets you colocate files safely next to routes: a folder in app becomes a public URL only when it contains a page or route file, and folders prefixed with _ are opted out of routing entirely.

By file type

  • Folders components/, hooks/, services/, types/, utils/
  • Clear from day one, convenient for a small project
  • One feature is spread across every folder — a change touches many places
  • Boundaries between features are invisible: any file can import any other

By feature

  • The features/cart/ folder holds its own UI, logic and API access
  • Things that change together live together (colocation)
  • A feature can be deleted or rewritten as a whole
  • Needs an agreement on boundaries — otherwise features grow together through deep imports

In our experience, a hybrid works: the top level is split by responsibility (app for routes, features for business capabilities, shared for code with no business meaning), and inside a feature files are arranged by purpose. Here is the tree of the verified example — a real Next.js project created with create-next-app and the --src-dir option:

terminal
$ find src -type f | sort
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/features/cart/api/cart-api.ts
src/features/cart/index.ts
src/features/cart/model/cart.ts
src/features/cart/ui/CartSummary.tsx
src/features/menu/index.ts
src/features/menu/model/menu.ts
src/features/menu/ui/MenuList.tsx
src/shared/api/http.ts
src/shared/lib/format-price.ts
src/shared/ui/Button.tsx
The real structure of the example: routes, two features and a shared layer

app/

What goes there
Routes, layouts, assembling a page from features
What doesn’t
Business logic and API requests

features/<name>/ui

What goes there
The feature’s components
What doesn’t
Parsing server responses

features/<name>/model

What goes there
Types, pure logic, the feature’s state
What doesn’t
JSX and fetch

features/<name>/api

What goes there
The feature’s requests and mapping of server data
What doesn’t
Components

shared/

What goes there
UI kit, HTTP client, formatting — with no knowledge of features
What doesn’t
Imports from features and app

Layers: UI, state, data

A layer is a group of modules with the same level of responsibility. The main rule of layers: dependencies point one way. A route assembles a page from features, a feature uses shared code, and shared code knows nothing about features or routes. Once shared starts importing from features, shared code stops being shared and cycles appear between layers.

  1. 01appRoutes and layouts: assemble a page from features
  2. 02featuresBusiness capabilities: cart, menu, lead form — each with its own UI, logic and API
  3. 03sharedCode with no business meaning: UI kit, HTTP client, formatting
Imports are allowed only from top to bottom

Inside a feature it pays to separate three things. The UI (ui) displays data and reports user actions. The model (model) holds types, domain rules and state: for example, cartTotal is a pure function that’s easy to test without a DOM. Data access (api) knows the server’s URLs and format. Place state by the rule from “Thinking in React”: in the closest common parent of the components that need it, and reach for a global store only when no such parent makes sense.

One formalisation of this idea is the Feature-Sliced Design methodology. It has seven standard layers (app, processes — deprecated, pages, widgets, features, entities, shared); a module may import only from layers strictly below it, and slices on the same layer don’t use each other. Its authors note that it targets applications rather than libraries, and that migrating is worth it if the current structure is genuinely causing trouble for the team. In our experience, a website or a small dashboard is well served by the three layers in the example; entities and widgets appear when several features use the same entities and blocks.

Module boundaries and public APIs

A module boundary answers the question “what may be used from outside, and what is internal”. JavaScript has no access modifiers for files, so the boundary is set by convention: a feature has an index.ts file that explicitly lists what it exposes. Everything else is an implementation detail that can be rewritten without searching the whole project.

src/features/cart/index.ts
export { CartSummary } from "./ui/CartSummary";
export { cartTotal } from "./model/cart";
export type { CartLine } from "./model/cart";
The cart’s public API: a component, a total function and a type — nothing more
src/features/menu/index.ts
export { MenuList } from "./ui/MenuList";
export { getMenu } from "./model/menu";
export type { MenuItem } from "./model/menu";
The menu’s public API
src/app/page.tsx
import { CartSummary, type CartLine } from "@/features/cart";
import { getMenu, MenuList } from "@/features/menu";

export default function HomePage() {
  const menu = getMenu();
  const lines: CartLine[] = [{ ...menu[0], qty: 2 }];

  return (
    <main>
      <h1>Coffee House</h1>
      <MenuList items={menu} />
      <CartSummary lines={lines} />
    </main>
  );
}
The route assembles the page from features and knows only their public APIs

The Feature-Sliced Design docs define a public API as a contract between a group of modules and the code that uses it, with two goals: protect the rest of the app from structural changes inside the module and expose only what’s necessary. They also list the risks of index files, which are worth knowing:

  • export * from … is convenient to write, but such a file no longer shows what the module exposes. That’s why the example lists exports by name.
  • An index file makes accidental circular imports easier: a file inside the feature imports its own index.ts, which imports that file. Inside a feature, use relative paths.
  • Some bundlers struggle to tree-shake through large indexes. This matters most for shared/ui and shared/lib: if the bundle grows, expose the shared layer per subfolder rather than through a single file.

Import rules: enforce them with a linter

An agreement no machine checks erodes within a few sprints. Two tools are enough here. A path alias in tsconfig.json gives the same absolute @/… path from anywhere. create-next-app sets it up for you, and according to its docs Next.js supports paths from tsconfig.json with no extra setup. The TypeScript docs stress that paths doesn’t change paths in emitted code, so the bundler must know the same mapping — in Next.js that is already handled.

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
Fragment: the @/ alias to the src folder (other create-next-app options omitted)

The second tool is ESLint’s built-in no-restricted-imports rule. Its patterns option takes .gitignore-style patterns (the group field) or regular expressions (the regex field) plus a custom message. We set the rules as separate flat config objects for different folders:

eslint.config.mjs
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

// Rule 1: other modules see a feature only through its public API (index.ts)
const publicApiOnly = {
  group: ["@/features/*/*"],
  message: "Import a feature through its public API: @/features/<name>.",
};

// Rule 2: lower layers do not know about upper ones
const noUpwardImports = (layers) => ({
  group: layers.flatMap((layer) => [`@/${layer}`, `@/${layer}/*`, `**/${layer}/**`]),
  message: `This layer must not import from: ${layers.join(", ")}.`,
});

const eslintConfig = defineConfig([
  ...nextVitals,
  ...nextTs,
  globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
  {
    files: ["src/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": ["error", { patterns: [publicApiOnly] }],
    },
  },
  {
    files: ["src/features/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": ["error", { patterns: [publicApiOnly, noUpwardImports(["app"])] }],
    },
  },
  {
    files: ["src/shared/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": [
        "error",
        { patterns: [publicApiOnly, noUpwardImports(["app", "features"])] },
      ],
    },
  },
]);

export default eslintConfig;
The create-next-app config plus two rules: import a feature only through its public API, and no upward imports

Let’s check that the rules fire. Three deliberate violations: a route reaches into the cart’s internals, one feature imports another’s component bypassing its public API, and the shared layer pulls code from a feature through a relative path.

TSX
// src/app/checkout/page.tsx
import { cartTotal } from "@/features/cart/model/cart";

export default function CheckoutPage() {
  return <p>{cartTotal([])}</p>;
}

// src/features/menu/model/upsell.ts
import { CartSummary } from "@/features/cart/ui/CartSummary";

export const upsell = CartSummary;

// src/shared/lib/order-total.ts
import { cartTotal, type CartLine } from "../../features/cart";

export const orderTotal = (lines: CartLine[]) => cartTotal(lines);
Three offending files (created for the check and then removed)
terminal
$ npx eslint src


src/app/checkout/page.tsx
  1:1  error  '@/features/cart/model/cart' import is restricted from being used by a pattern. Import a feature through its public API: @/features/<name>  no-restricted-imports

src/features/menu/model/upsell.ts
  1:1  error  '@/features/cart/ui/CartSummary' import is restricted from being used by a pattern. Import a feature through its public API: @/features/<name>  no-restricted-imports

src/shared/lib/order-total.ts
  1:1  error  '../../features/cart' import is restricted from being used by a pattern. This layer must not import from: app, features  no-restricted-imports

✖ 3 problems (3 errors, 0 warnings)
$ echo $?
1
Real ESLint 9.39 output: three errors, exit code 1 — a CI build would stop

Once these files are removed, tsc --noEmit, eslint . and next build all pass. Know the limits of the approach. Patterns are matched against the import string, not the resolved file path, so relative paths are covered by separate patterns such as **/features/**. Inside a feature, import relatively, or the feature’s own @/features/cart/model/cart will be forbidden too. For more complex schemes — many layers, no links between features, different rules for types — there are dedicated plugins such as eslint-plugin-boundaries. We didn’t test it here, so we only mention it.

Where API access lives

A request to the server combines two different kinds of knowledge: how to talk to the network (headers, error handling, base URL) and what exactly to ask a particular API (URL, format, field names). The first is shared code, the second belongs to a feature. Mix them inside a component and the server format leaks across the UI, so renaming one field turns into a project-wide search.

src/shared/api/http.ts
export class HttpError extends Error {
  constructor(public readonly status: number, url: string) {
    super(`HTTP ${status} for ${url}`);
    this.name = "HttpError";
  }
}

export async function postJson<T>(url: string, body: unknown): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new HttpError(res.status, url);
  return (await res.json()) as T;
}
A shared HTTP client: knows nothing about carts or orders
src/features/cart/api/cart-api.ts
import { postJson } from "@/shared/api/http";
import type { CartLine } from "../model/cart";

type OrderDto = { items: { product_id: string; quantity: number }[] };

function toOrderDto(lines: CartLine[]): OrderDto {
  return { items: lines.map((line) => ({ product_id: line.id, quantity: line.qty })) };
}

export function submitOrder(lines: CartLine[]): Promise<{ orderId: string }> {
  return postJson("/api/orders", toOrderDto(lines));
}
The feature’s API: an adapter maps the cart model to the server format (snake_case)
src/features/cart/ui/CartSummary.tsx
"use client";

import { useState } from "react";
import { formatPrice } from "@/shared/lib/format-price";
import { Button } from "@/shared/ui/Button";
import { submitOrder } from "../api/cart-api";
import { cartTotal, type CartLine } from "../model/cart";

export function CartSummary({ lines }: { lines: CartLine[] }) {
  const [status, setStatus] = useState<"idle" | "sent" | "error">("idle");

  async function handleOrder() {
    try {
      await submitOrder(lines);
      setStatus("sent");
    } catch {
      setStatus("error");
    }
  }

  return (
    <section aria-label="Cart">
      <p>Total: {formatPrice(cartTotal(lines))}</p>
      <Button onClick={handleOrder}>Place order</Button>
      <p role="status">{status === "sent" ? "Order sent" : status === "error" ? "Could not send" : ""}</p>
    </section>
  );
}
The UI knows the model and calls submitOrder, but not the server format
  • The adapter (toOrderDto) is the only place where the app’s model meets the server format. If the server renames product_id, the change is in one function.
  • Server response types (DTOs, data transfer objects) don’t leave api: the UI works with CartLine, not with JSON field names.
  • In Next.js, page data is often fetched in Server Components, while mutations happen from Client Components or through server functions. The ui / model / api split doesn’t change — only where the api function is called does. More in the lesson on data and Server Components.

Monorepo and a shared UI kit: when it’s time

A monorepo is several packages (applications and libraries) in one repository with shared dependencies and tooling. This is an opinion rather than a rule, but in our experience moving code into a separate package makes sense once there is a second consumer: another application (a dashboard and a website, web and mobile) that uses the same UI kit or the same API types. While there is only one application, a shared/ui folder with an import rule gives almost the same isolation without the cost of versioning, building packages and configuring CI.

One application, one team

What is usually enough
app / features / shared folders and import rules in ESLint

A design system with its own owner appears

What is usually enough
The UI kit as a separate monorepo package, with the package’s public API instead of a folder’s index.ts

Several applications sharing types and an API client

What is usually enough
A monorepo with ui, api-client and config packages and shared lint rules

Whatever the structure, the quality check is the same: can you rewrite or delete a feature without touching the others, and does a newcomer know where to put a file? If both answers are “yes”, the structure works, even if it matches no methodology.

How this was verified4 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.