Архитектура фронтенда — это договорённость о том, где лежит код и кто кого может импортировать. Рабочая основа для растущего проекта: группировать код по фичам, внутри фичи разделять интерфейс, логику и доступ к данным, открывать фичу наружу только через публичный API (index.ts) и запрещать импорт «вверх» — из общих модулей в фичи. Эти правила стоит проверять линтером, а не держать в голове: React и Next.js структуру не навязывают, поэтому её поддерживает только команда.
После материала вы сможете
- предложить структуру папок по фичам с разделением
ui/model/apiи обосновать её против группировки по типам - описать слои
app→features→sharedи объяснить, почему импорт «вверх» создаёт связанность и циклы - оформить публичный API фичи через
index.tsи назвать риски индексных файлов - закрепить правила импорта через
no-restricted-importsв flat config ESLint и проверить, что они срабатывают на нарушениях
Что знать заранее
Проверено на
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)
Сверено с документацией: 11 октября 2026 г.
Содержание
Симптомы: когда структура перестала работать
Начнём с фактов. React не задаёт структуру папок: в старой документации React прямо сказано, что у него нет мнения о том, как раскладывать файлы. Документация Next.js называет фреймворк «unopinionated» в вопросе организации файлов проекта и советует выбрать стратегию, которая подходит команде, и последовательно её придерживаться. Значит, архитектура — решение команды, и проверять его будет не фреймворк, а вы.
Дальше — наблюдения, по которым видно, что решение пора пересмотреть. Это не метрики, а признаки из практики:
- Правка в одной фиче ломает другую, и это обнаруживается не на ревью, а у пользователя
- Чтобы добавить поле в форму, нужно открыть пять папок:
components,hooks,services,types,utils - В
utilsиcommonлежит всё подряд, и никто не решается там ничего удалить - Импорты вида
../../../../components/...и циклические зависимости между модулями - Компонент интерфейса сам делает
fetch, разбирает ответ сервера и хранит глобальное состояние - Новый разработчик не может ответить, куда положить новый файл, без вопроса в чат
По типам или по фичам
Старая документация React описывает два распространённых подхода: группировку по фичам или маршрутам и группировку по типу файлов. Next.js в разделе об организации проекта перечисляет три стратегии: держать код вне app, держать его в общих папках внутри app или разделять по фичам и маршрутам, оставляя в корне только общее. Next.js безопасно позволяет класть файлы рядом с маршрутами: папка в app становится публичным адресом, только когда в ней есть page или route, а папки с префиксом _ полностью исключаются из маршрутизации.
По типам файлов
- Папки
components/,hooks/,services/,types/,utils/ - Понятно с первого дня, удобно для маленького проекта
- Одна фича размазана по всем папкам — правка затрагивает много мест
- Границы между фичами не видны: любой файл может импортировать любой
По фичам
- Папка
features/cart/содержит свой UI, логику и работу с API - Всё, что меняется вместе, лежит вместе (колокация)
- Фичу можно удалить или переписать целиком
- Нужна договорённость о границах — иначе фичи срастаются через глубокие импорты
По нашему опыту, работает гибрид: верхний уровень делится по ответственности (app — маршруты, features — бизнес-возможности, shared — код без бизнес-смысла), а внутри фичи файлы раскладываются по назначению. Вот дерево проверенного примера — это реальный Next.js-проект, созданный create-next-app с опцией --src-dir:
$ 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/
- Что лежит
- Маршруты, раскладки, сборка страницы из фич
- Что не лежит
- Бизнес-логика и запросы к API
features/<name>/ui
- Что лежит
- Компоненты фичи
- Что не лежит
- Разбор ответа сервера
features/<name>/model
- Что лежит
- Типы, чистая логика, состояние фичи
- Что не лежит
- JSX и
fetch
features/<name>/api
- Что лежит
- Запросы фичи и преобразование данных сервера
- Что не лежит
- Компоненты
shared/
- Что лежит
- UI-кит, HTTP-клиент, форматирование — без знания о фичах
- Что не лежит
- Импорты из
featuresиapp
Слои: интерфейс, состояние, данные
Слой — группа модулей с одним уровнем ответственности. Главное правило слоёв: зависимости направлены в одну сторону. Маршрут собирает страницу из фич, фича пользуется общим кодом, общий код не знает ни о фичах, ни о маршрутах. Если shared начинает импортировать из features, общий код перестаёт быть общим, а между слоями появляются циклы.
- 01appМаршруты и раскладки: собирают страницу из фич
- 02featuresБизнес-возможности: корзина, меню, заявка — со своим UI, логикой и API
- 03sharedКод без бизнес-смысла: UI-кит, HTTP-клиент, форматирование
Внутри фичи полезно отделять три вещи. Интерфейс (ui) показывает данные и сообщает о действиях пользователя. Модель (model) содержит типы, правила предметной области и состояние: например, cartTotal — чистая функция, которую легко протестировать без DOM. Доступ к данным (api) знает адреса и формат сервера. Состояние размещайте по правилу из «Thinking in React»: в ближайшем общем родителе компонентов, которым оно нужно, а глобальное хранилище — только когда такого родителя разумно не найти.
Одна из формализаций этой идеи — методология Feature-Sliced Design. В ней семь стандартных слоёв (app, processes — устаревший, pages, widgets, features, entities, shared), модуль может импортировать только из слоёв строго ниже, а срезы одного слоя не используют друг друга. Сами авторы оговаривают, что методология рассчитана на приложения, а не на библиотеки, и что переходить на неё стоит, если текущая структура реально мешает команде. По нашему опыту, для сайта или небольшого кабинета хватает трёх слоёв из примера; entities и widgets появляются, когда одни и те же сущности и блоки используют несколько фич.
Границы модулей и публичный API
Граница модуля — это ответ на вопрос «что снаружи можно использовать, а что является внутренним устройством». В JavaScript нет модификатора доступа для файлов, поэтому границу задают соглашением: у фичи есть файл index.ts, который явно перечисляет, что она отдаёт наружу. Всё остальное — детали реализации, которые можно переписать без поиска по всему проекту.
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>
);
}Документация Feature-Sliced Design определяет публичный API как контракт между группой модулей и кодом, который ими пользуется, и называет две цели: защитить остальное приложение от структурных изменений внутри модуля и открывать только необходимое. Там же перечислены риски индексных файлов, и их стоит знать:
export * from …удобно писать, но по такому файлу уже не видно, что модуль отдаёт наружу. Поэтому в примере экспорты перечислены по именам.- Индексный файл облегчает случайный циклический импорт: файл внутри фичи импортирует собственный
index.ts, а тот — этот файл. Внутри фичи используйте относительные пути. - Некоторым сборщикам тяжело выполнять tree-shaking через большие индексы. Особенно это касается
shared/uiиshared/lib: если бандл растёт, общий слой лучше открывать по подпапкам, а не одним файлом.
Правила импорта: закрепляем линтером
Договорённость, которую не проверяет машина, разрушается за несколько спринтов. Здесь хватает двух инструментов. Алиас пути в tsconfig.json даёт одинаковый абсолютный путь @/… из любого места. create-next-app настраивает его сам, а Next.js по документации поддерживает paths из tsconfig.json без дополнительной настройки. Документация TypeScript подчёркивает, что paths не меняет пути в скомпилированном коде, поэтому ту же схему должен знать сборщик — в Next.js это уже сделано.
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}Второй инструмент — встроенное правило ESLint no-restricted-imports. Его опция patterns принимает шаблоны в стиле .gitignore (поле group) или регулярные выражения (поле regex) и своё сообщение об ошибке. Правила задаём отдельными объектами flat config для разных папок:
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;Проверим, что правила срабатывают. Три намеренных нарушения: маршрут лезет во внутренности корзины, одна фича импортирует компонент другой мимо публичного API, общий слой через относительный путь тянет код из фичи.
// 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 $?
1После удаления этих файлов tsc --noEmit, eslint . и next build проходят без ошибок. Ограничения подхода стоит знать. Шаблоны сравниваются со строкой импорта, а не с разрешённым путём к файлу, поэтому относительные пути закрываются отдельными шаблонами вида **/features/**. Внутри фичи нужно импортировать относительно, иначе собственный @/features/cart/model/cart тоже попадёт под запрет. Для сложных схем — много слоёв, запрет связей между фичами, разные правила для типов — есть специализированные плагины, например eslint-plugin-boundaries. Мы его здесь не проверяли, поэтому только упоминаем.
Где живёт работа с API
Запрос к серверу состоит из двух разных знаний: как ходить в сеть (заголовки, обработка ошибок, базовый адрес) и что именно спрашивать у конкретного API (адрес, формат, названия полей). Первое — общий код, второе — часть фичи. Если смешать их в компоненте, формат сервера расползётся по интерфейсу, и переименование одного поля превратится в поиск по всему проекту.
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>
);
}- Адаптер (
toOrderDto) — единственное место, где модель приложения встречается с форматом сервера. Если сервер переименуетproduct_id, правка будет в одной функции. - Типы ответа сервера (DTO, data transfer object — объект передачи данных) не выходят за пределы
api: интерфейс работает сCartLine, а не с полями из JSON. - В Next.js данные для страницы часто получают в серверных компонентах, а мутации делают из клиентских или через серверные функции. Разделение
ui/model/apiот этого не меняется — меняется только то, где вызывается функция изapi. Подробнее — в уроке о данных и Server Components.
Монорепозиторий и общий UI-кит: когда пора
Монорепозиторий — несколько пакетов (приложений и библиотек) в одном репозитории с общими зависимостями и инструментами. Это мнение, а не правило, но по нашему опыту выносить код в отдельный пакет имеет смысл, когда появился второй потребитель: второе приложение (кабинет и сайт, веб и мобильное), которое использует тот же UI-кит или те же типы API. Пока приложение одно, папка shared/ui с правилом импорта даёт почти ту же изоляцию без расходов на версионирование, сборку пакетов и настройку CI.
Одно приложение, одна команда
- Что обычно достаточно
- Папки
app/features/sharedи правила импорта в ESLint
Появилась дизайн-система с отдельным владельцем
- Что обычно достаточно
- UI-кит в отдельном пакете монорепозитория, публичный API пакета вместо
index.tsпапки
Несколько приложений с общими типами и клиентом API
- Что обычно достаточно
- Монорепозиторий с пакетами
ui,api-client,configи общими правилами линтера
Как бы ни была устроена структура, проверка её качества одна: можно ли переписать или удалить фичу, не трогая остальные, и понимает ли новый человек, куда положить файл. Если оба ответа «да», структура работает, даже если она не совпадает ни с одной методологией.
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
Next.js-проект (create-next-app 16.4.0 --ts --eslint --app --src-dir): src/app, features/{cart,menu}, shared/{api,lib,ui}, eslint.config.mjs, tsconfig paths
tsc --noEmit (TypeScript 5.9.3) — без ошибок; eslint . (ESLint 9.39.5, eslint-config-next 16.4.0) — чисто; next build (Next.js 16.4.0, Turbopack, React 19.3.0) — успешно, / собран статически; Node.js 24.18.0. Google-шрифты из шаблона layout убраны, чтобы сборка не зависела от сети
- запущено, работает
три файла-нарушителя (checkout/page.tsx, menu/model/upsell.ts, shared/lib/order-total.ts)
npx eslint src — 3 errors, exit 1; вывод в статье реальный, абсолютные пути сокращены до src/
- запущено, работает
переопределение опций правила в flat config
блок src/features/** без publicApiOnly — глубокий импорт @/features/cart/ui/CartSummary не ловится (exit 0); с ним — ловится
- запущено, работает
решение упражнения (noCrossFeature, eslint.exercise.mjs)
npx eslint -c eslint.exercise.mjs src: импорт @/features/cart из фичи menu — 1 error; без нарушителя — чисто; базовый конфиг этот импорт пропускает
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.