Перейти к содержимому
·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-ов. Каждая группа маршрутов получает свой 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 />
    </>
  );
}

Проверка авторизации происходит один раз на уровне layout, а не на каждой странице. Публичные страницы получают совершенно другую оболочку. Страницы авторизации вообще не имеют layout — только карточка по центру. Это чисто, явно и сложно испортить.

Я видел, как команды размещали проверки авторизации одновременно в middleware, на отдельных страницах и внутри компонентов. Выберите одно место. Для большинства приложений layout группы маршрутов — это и есть то самое место.

Папка 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-сервер стал загружать страницы за менее чем 2 секунды вместо 5-10 секунд. 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-ах, если изменение в биллинге почему-то ломает дашборд — значит, структура работает против вас. И паттерны выше — это то, как я это исправлял, каждый раз.

Похожие записи