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.
After this you will be able to
- Propose a feature-based folder structure with a
ui/model/apisplit and argue for it against grouping by type - Describe the
app→features→sharedlayers and explain why upward imports create coupling and cycles - Shape a feature’s public API with
index.tsand name the risks of index files - Enforce import rules with
no-restricted-importsin ESLint flat config and verify they fire on violations
Know beforehand
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
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 utilsandcommonhold everything, and nobody dares delete anything there- Imports like
../../../../components/...and circular dependencies between modules - A UI component calls
fetchitself, 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:
$ 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.tsxapp/
- 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
featuresandapp
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.
- 01appRoutes and layouts: assemble a page from features
- 02featuresBusiness capabilities: cart, menu, lead form — each with its own UI, logic and API
- 03sharedCode with no business meaning: UI kit, HTTP client, formatting
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.
export { CartSummary } from "./ui/CartSummary";
export { cartTotal } from "./model/cart";
export type { CartLine } from "./model/cart";export { MenuList } from "./ui/MenuList";
export { getMenu } from "./model/menu";
export type { MenuItem } from "./model/menu";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 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/uiandshared/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.
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}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:
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;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.
// 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);$ 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 $?
1Once 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.
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;
}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));
}"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 adapter (
toOrderDto) is the only place where the app’s model meets the server format. If the server renamesproduct_id, the change is in one function. - Server response types (DTOs, data transfer objects) don’t leave
api: the UI works withCartLine, 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/apisplit doesn’t change — only where theapifunction 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/sharedfolders 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-clientandconfigpackages 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.