Структурирование проектов на Next.js для масштабирования: уроки, усвоенные на собственном опыте
Выстраданные уроки по организации кодовых баз Next.js, которые не рушатся под собственным весом. Фича-ориентированная архитектура, группы маршрутов, граница сервер/клиент, ловушки barrel-файлов и реальная структура папок.
Каждый проект на Next.js начинается чисто. Несколько страниц, папка components, может быть lib/utils.ts. Ты чувствуешь себя продуктивным. Ты чувствуешь себя организованным.
Потом проходит полгода. Приходят трое новых разработчиков. В папке components уже 140 файлов. Кто-то создал utils2.ts, потому что utils.ts разросся до 800 строк. Никто не знает, какой layout оборачивает какой маршрут. Сборка занимает четыре минуты, и ты не уверен почему.
Я проходил через этот цикл неоднократно. Далее — то, что я реально делаю сейчас, после того как усвоил большинство этих уроков, сначала выпуская неправильное решение.
Дефолтная структура разваливается быстро#
В документации Next.js предлагается структура проекта. Она нормальная. Работает для личного блога или маленького инструмента. Но как только больше двух разработчиков работают в одной кодовой базе, подход «по типам» начинает трещать по швам:
src/
components/
Button.tsx
Modal.tsx
UserCard.tsx
DashboardSidebar.tsx
InvoiceTable.tsx
InvoiceRow.tsx
InvoiceActions.tsx
...137 more files
hooks/
useAuth.ts
useInvoice.ts
useDebounce.ts
utils/
format.ts
validate.ts
misc.ts <- начало конца
app/
page.tsx
dashboard/
page.tsx
Проблема не в том, что это «неправильно». Проблема в том, что это масштабируется линейно по количеству фич, но не по понятности. Когда новому разработчику нужно поработать над счетами, ему приходится открывать components/, hooks/, utils/, types/ и app/ одновременно. Фича счетов разбросана по пяти директориям. Нет единого места, которое говорит: «вот как выглядит работа со счетами».
Я наблюдал, как команда из четырех разработчиков потратила целый спринт на рефакторинг именно в такую структуру, будучи убежденными, что это «чистый» подход. Через три месяца они снова были в замешательстве — просто с большим количеством папок.
Фича-ориентированная колокация побеждает. Всегда.#
Паттерн, который реально пережил столкновение с настоящей командой — это фича-ориентированная колокация. Идея проста: всё, что относится к фиче, живет вместе.
Не «все компоненты в одной папке, а все хуки в другой». Наоборот. Все компоненты, хуки, типы и утилиты для счетов живут внутри features/invoicing/. Всё для авторизации — внутри features/auth/.
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
export function useInvoiceActions(invoice: Invoice) {
const markAsPaid = useCallback(async () => {
await invoiceApi.updateStatus(invoice.id, "paid");
}, [invoice.id]);
const duplicate = useCallback(async () => {
const newNumber = formatInvoiceNumber(Date.now());
await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
}, [invoice]);
return { markAsPaid, duplicate };
}Обратите внимание на импорты. Все они относительные, все внутри фичи. Этот хук не лезет в какую-то глобальную папку hooks/ или общий utils/format.ts. Он использует собственную логику форматирования фичи счетов, потому что форматирование номера счета — это задача модуля счетов, а не глобальная.
Контринтуитивный момент: это означает, что у вас будет некоторое дублирование. В фиче счетов может быть свой format.ts, и в фиче биллинга тоже может быть свой format.ts. И это нормально. Это на самом деле лучше, чем общий formatters.ts, от которого зависят 14 фич и который никто не может безопасно изменить.
Я долго сопротивлялся этому. DRY был евангелием. Но я видел, как общие утилитные файлы становились самыми опасными файлами в кодовой базе — файлами, где «маленький фикс» для одной фичи ломал три другие. Колокация с контролируемым дублированием более поддерживаема, чем принудительное переиспользование.
Структура папок, которую я реально использую#
Вот полное дерево. Оно проверено в бою в двух продакшен-приложениях с командами по 4-8 человек:
src/
├── app/
│ ├── (public)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── pricing/
│ │ │ └── page.tsx
│ │ └── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── invoices/
│ │ │ ├── page.tsx
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx
│ │ │ └── new/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── layout.tsx
│ └── not-found.tsx
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── login-form.tsx
│ │ │ ├── register-form.tsx
│ │ │ └── oauth-buttons.tsx
│ │ ├── actions/
│ │ │ ├── login.ts
│ │ │ └── register.ts
│ │ ├── hooks/
│ │ │ └── use-session.ts
│ │ ├── types.ts
│ │ └── constants.ts
│ ├── invoicing/
│ │ ├── components/
│ │ │ ├── invoice-table.tsx
│ │ │ ├── invoice-row.tsx
│ │ │ ├── invoice-form.tsx
│ │ │ └── invoice-pdf.tsx
│ │ ├── actions/
│ │ │ ├── create-invoice.ts
│ │ │ └── update-status.ts
│ │ ├── hooks/
│ │ │ └── use-invoice-actions.ts
│ │ ├── api/
│ │ │ └── client.ts
│ │ ├── utils/
│ │ │ └── format.ts
│ │ └── types.ts
│ └── billing/
│ ├── components/
│ ├── actions/
│ ├── hooks/
│ └── types.ts
├── shared/
│ ├── components/
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ ├── modal.tsx
│ │ │ └── toast.tsx
│ │ └── layout/
│ │ ├── header.tsx
│ │ ├── footer.tsx
│ │ └── sidebar.tsx
│ ├── hooks/
│ │ ├── use-debounce.ts
│ │ └── use-media-query.ts
│ ├── lib/
│ │ ├── db.ts
│ │ ├── auth.ts
│ │ ├── stripe.ts
│ │ └── email.ts
│ └── types/
│ └── global.ts
└── config/
├── site.ts
└── navigation.ts
На что стоит обратить внимание:
app/содержит только логику маршрутизации. Файлы страниц тонкие. Они импортируют изfeatures/и компонуют. Файл страницы должен быть 20-40 строк, максимум.features/— это место, где живет настоящий код. Каждая фича самодостаточна. Можно удалитьfeatures/invoicing/, и ничего другого не сломается (кроме страниц, которые из неё импортируют — а это именно та связанность, которая вам нужна).shared/— это не свалка. Там есть строгие подкатегории. Подробнее об этом ниже.
Группы маршрутов: больше, чем просто организация#
Группы маршрутов — папки в (скобках) — одна из самых недооцененных возможностей App Router. Они вообще не влияют на URL. (dashboard)/invoices/page.tsx рендерится по пути /invoices, а не /dashboard/invoices.
Но настоящая сила — в изоляции layout-ов. Каждая группа маршрутов получает свой layout.tsx:
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen">
<Sidebar user={session.user} />
<main className="flex-1 p-6">{children}</main>
</div>
);
}// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
);
}Проверка авторизации происходит один раз на уровне layout, а не на каждой странице. Публичные страницы получают совершенно другую оболочку. Страницы авторизации вообще не имеют layout — только карточка по центру. Это чисто, явно и сложно испортить.
Я видел, как команды размещали проверки авторизации одновременно в middleware, на отдельных страницах и внутри компонентов. Выберите одно место. Для большинства приложений layout группы маршрутов — это и есть то самое место.
Папка shared/ — это не utils/#
Самый быстрый способ создать кошмар для поддержки — файл lib/utils.ts. Он начинается маленьким. Потом становится мусорным ящиком кодовой базы, куда попадает каждая функция, у которой нет очевидного дома.
Вот мое правило: папка shared/ требует обоснования. Что-то попадает в shared/ только если:
- Используется 3+ фичами, и
- Действительно является общим (а не бизнес-логикой в обертке общей функции)
Функция форматирования даты, которая форматирует срок оплаты счета? Она идет в features/invoicing/utils/. Функция форматирования даты, которая принимает Date и возвращает локализованную строку? Она может попасть в shared/.
Подпапка shared/lib/ — это специально для интеграций со сторонними сервисами и инфраструктуры: клиенты баз данных, библиотеки авторизации, платежные провайдеры. Это то, от чего зависит всё приложение, но ни одна конкретная фича не владеет этим.
// shared/lib/db.ts — это принадлежит shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// features/invoicing/utils/format.ts — это НЕ принадлежит shared
export function formatInvoiceNumber(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const seq = Math.floor(Math.random() * 9000) + 1000;
return `INV-${year}-${seq}`;
}Если вы тянетесь к shared/utils/ и не можете назвать файл чем-то более конкретным, чем helpers.ts, остановитесь. Этот код, скорее всего, принадлежит какой-то фиче.
Серверные vs. клиентские компоненты: проводите границу осознанно#
Вот паттерн, который я постоянно вижу в кодовых базах с проблемами производительности:
// Выглядит разумно, но это ошибка
"use client";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
export default function InvoicesPage() {
const { data } = useInvoiceActions();
return (
<div>
<InvoiceFilters />
<InvoiceTable invoices={data} />
</div>
);
}Страница помечена "use client". Теперь всё, что она импортирует — InvoiceTable, InvoiceFilters, хук, каждая зависимость каждой зависимости — попадает в клиентский бандл. Ваша таблица на 200 строк, которую можно было отрендерить на сервере с нулевым количеством JavaScript, теперь отправляется в браузер.
Решение — опустить "use client" до листовых компонентов, которым это реально нужно:
// app/(dashboard)/invoices/page.tsx — серверный компонент (без директивы)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
export default async function InvoicesPage() {
const invoices = await db.query.invoices.findMany({
orderBy: (inv, { desc }) => [desc(inv.createdAt)],
});
return (
<div>
<InvoiceFilters /> {/* Клиентский компонент — есть интерактивные фильтры */}
<InvoiceTable data={invoices} /> {/* Серверный компонент — просто рендерит строки */}
</div>
);
}// features/invoicing/components/invoice-filters.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export function InvoiceFilters() {
const router = useRouter();
const params = useSearchParams();
const setStatus = (status: string) => {
const next = new URLSearchParams(params.toString());
next.set("status", status);
router.push(`?${next.toString()}`);
};
return (
<div className="flex gap-2">
{["all", "draft", "sent", "paid"].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={params.get("status") === s ? "font-bold" : ""}
>
{s}
</button>
))}
</div>
);
}Правило, которому я следую: каждый компонент — серверный, пока ему не понадобится useState, useEffect, обработчики событий или браузерные API. Только тогда добавляйте "use client" — и старайтесь сделать этот компонент как можно меньше.
Паттерн children — ваш лучший друг. Клиентский компонент может получать серверные компоненты через children:
"use client";
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* children могут быть серверными компонентами */}
</div>
);
}Именно так вы держите 90% дерева компонентов на сервере, сохраняя интерактивность там, где это важно.
Barrel-файлы: тихий убийца сборки#
Раньше я ставил index.ts в каждую папку. Чистые импорты, правда?
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";Тогда я мог импортировать вот так:
import { InvoiceTable } from "@/features/invoicing/components";Выглядит красиво. Вот в чем проблема: когда бандлер видит этот импорт, ему нужно обработать весь barrel-файл, чтобы понять, что такое InvoiceTable. Это значит, что он загружает InvoiceRow, InvoiceForm и InvoicePdf тоже, даже если вам нужна только таблица.
В одном проекте удаление внутренних barrel-файлов сократило количество модулей с 11 000 до 3 500 при разработке. Dev-сервер стал загружать страницы за менее чем 2 секунды вместо 5-10 секунд. JavaScript при первой загрузке упал с более чем мегабайта до примерно 200 КБ.
Vercel создали optimizePackageImports в next.config.ts для решения этой проблемы со сторонними пакетами из node_modules. Но это не исправляет ваши собственные barrel-файлы. Для вашего кода ответ проще: не используйте их.
// Делайте так
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// Не так
import { InvoiceTable } from "@/features/invoicing/components";Да, путь импорта длиннее. Зато сборка быстрее, бандлы меньше, и dev-сервер не захлебывается. Этот компромисс я приму в любой момент.
Единственное исключение: публичное API фичи. Если features/invoicing/ экспортирует единственный index.ts на корневом уровне фичи с 2-3 вещами, которые другие фичи могут импортировать — это нормально. Он выступает границей, а не ярлыком для удобства. Но barrel-файлы внутри подпапок типа components/index.ts или hooks/index.ts? Убивайте их.
Тонкие страницы, толстые фичи#
Самое важное архитектурное правило, которому я следую: файлы страниц — это связующий код, а не логика.
Файл страницы в app/ должен делать три вещи:
- Получать данные (если это серверный компонент)
- Импортировать компоненты фич
- Компоновать их вместе
Всё. Никакой бизнес-логики. Никакого сложного условного рендеринга. Никаких JSX-деревьев на 200 строк. Если файл страницы растет за 40 строк — вы кладете код фичи не туда.
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
interface Props {
params: Promise<{ id: string }>;
}
export default async function InvoicePage({ params }: Props) {
const { id } = await params;
const invoice = await db.query.invoices.findFirst({
where: (inv, { eq }) => eq(inv.id, id),
});
if (!invoice) notFound();
return <InvoiceDetail invoice={invoice} />;
}Двенадцать строк. Страница получает нужные данные, обрабатывает случай «не найдено» и делегирует всё остальное фиче. Если UI детализации счета меняется, вы редактируете features/invoicing/, а не app/. Если маршрут меняется, вы перемещаете файл страницы, а код фичи об этом не знает.
Именно так вы получаете кодовую базу, где 15 разработчиков могут работать одновременно, не мешая друг другу.
Неудобная правда#
Не существует структуры папок, которая предотвращает плохой код. Я видел прекрасно организованные проекты, полные ужасных абстракций, и хаотичные плоские структуры, в которых было легко работать, потому что сам код был понятным.
Структура — это инструмент, а не цель. Фича-ориентированный подход, который я описал, работает, потому что оптимизирует то, что важнее всего при масштабировании: может ли разработчик, который никогда не видел эту кодовую базу, найти то, что ему нужно, и изменить это, ничего не сломав?
Если ваша текущая структура отвечает «да» на этот вопрос — не рефакторьте её, потому что блог-пост вам так сказал, даже этот.
Но если ваши разработчики тратят больше времени на навигацию по кодовой базе, чем на написание кода, если «куда это положить?» — постоянный вопрос в PR-ах, если изменение в биллинге почему-то ломает дашборд — значит, структура работает против вас. И паттерны выше — это то, как я это исправлял, каждый раз.