Уроки, здобуті важким шляхом, про організацію кодових баз 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/ лише якщо:
Функція форматування дати, яка форматує терміни оплати інвойсів? Це йде в 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, зупиніться. Цей код, ймовірно, належить до фічі.
Ось патерн, який я постійно бачу в кодових базах з проблемами продуктивності:
// Це виглядає розумно, але це помилка
"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% свого дерева компонентів на сервері, зберігаючи інтерактивність там, де вона потрібна.
Раніше я ставив 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-ах, якщо зміна в білінгу чомусь ламає дашборд — тоді ваша структура працює проти вас. І патерни вище — ось як я це виправляв кожного разу.