Структурування проєктів 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.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 />
</>
);
}Перевірка авторизації відбувається один раз на рівні лейауту, а не на кожній сторінці. Публічні сторінки отримують зовсім іншу оболонку. Сторінки авторизації не мають лейауту взагалі — просто відцентрована картка. Це чисто, явно і складно зіпсувати.
Я бачив, як команди одночасно ставили перевірки авторизації в middleware, на окремих сторінках і в компонентах. Виберіть одне місце. Для більшості додатків лейаут групи маршрутів — це саме те місце.
Папка 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-сервер перейшов від 5-10 секунд завантаження сторінок до менш ніж 2 секунд. 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-ах, якщо зміна в білінгу чомусь ламає дашборд — тоді ваша структура працює проти вас. І патерни вище — ось як я це виправляв кожного разу.