Перейти до вмісту
·10 хв читання

Структурування проєктів Next.js для масштабування: чого я навчився на власних помилках

Уроки, здобуті важким шляхом, про організацію кодових баз Next.js, які не руйнуються під власною вагою. Фіча-орієнтована архітектура, групи маршрутів, межі сервер/клієнт, пастки barrel-файлів і реальна структура папок.

Поділитися:X / TwitterLinkedIn

Кожен проєкт 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/.

typescript
// 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:

typescript
// 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>
  );
}
typescript
// 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/ лише якщо:

  1. Це використовується 3+ фічами, та
  2. Це справді загальне (а не бізнес-логіка в загальному маскуванні)

Функція форматування дати, яка форматує терміни оплати інвойсів? Це йде в features/invoicing/utils/. Функція форматування дати, яка приймає Date і повертає рядок з локаллю? Це може піти в shared/.

Підпапка shared/lib/ призначена спеціально для інтеграцій з третіми сторонами та інфраструктури: клієнти бази даних, бібліотеки авторизації, платіжні провайдери. Це речі, від яких залежить весь додаток, але жодна окрема фіча не є їх власником.

typescript
// 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. клієнтські компоненти: проводьте межу свідомо#

Ось патерн, який я постійно бачу в кодових базах з проблемами продуктивності:

typescript
// Це виглядає розумно, але це помилка
"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" до листових компонентів, яким це реально потрібно:

typescript
// 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>
  );
}
typescript
// 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:

typescript
"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 у кожну папку. Чисті імпорти, так?

typescript
// 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";

Тоді я міг імпортувати ось так:

typescript
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-файли. Для вашого власного коду відповідь простіша: не використовуйте їх.

typescript
// Робіть так
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/ повинен робити три речі:

  1. Отримувати дані (якщо це серверний компонент)
  2. Імпортувати компоненти фіч
  3. Композирувати їх разом

Це все. Ніякої бізнес-логіки. Ніякого складного умовного рендерингу. Ніяких JSX-дерев на 200 рядків. Якщо файл сторінки росте понад 40 рядків, ви кладете код фічі не в те місце.

typescript
// 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-ах, якщо зміна в білінгу чомусь ламає дашборд — тоді ваша структура працює проти вас. І патерни вище — ось як я це виправляв кожного разу.

Схожі записи