Przejdź do treści
·11 min czytania

Strukturyzowanie projektów Next.js na dużą skalę: czego nauczyłem się na własnej skórze

Ciężko wywalczone lekcje o organizowaniu baz kodu Next.js, które nie zapadają się pod własnym ciężarem. Architektura oparta na funkcjonalnościach, grupy tras, granice serwer/klient, pułapki barrel file i prawdziwa struktura folderów.

Udostępnij:X / TwitterLinkedIn

Każdy projekt Next.js zaczyna się czysto. Kilka stron, folder components, może lib/utils.ts. Czujesz się produktywny. Czujesz się zorganizowany.

Potem mija sześć miesięcy. Dołącza trzech programistów. Folder components ma 140 plików. Ktoś stworzył utils2.ts, bo utils.ts miał 800 linii. Nikt nie wie, który layout opakowuje którą trasę. Build trwa cztery minuty i nie do końca wiadomo dlaczego.

Przez ten cykl przeszedłem wielokrotnie. Poniżej opisuję to, co faktycznie robię teraz, po tym jak większość tych lekcji wyciągnąłem, najpierw dostarczając coś złego.

Domyślna struktura szybko się rozpada#

Dokumentacja Next.js sugeruje strukturę projektu. Jest w porządku. Działa dla osobistego bloga lub małego narzędzia. Ale w momencie, gdy więcej niż dwóch programistów pracuje w tym samym kodzie, podejście "oparte na typach" zaczyna pękać:

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     ← początek końca
  app/
    page.tsx
    dashboard/
      page.tsx

Problem nie polega na tym, że to jest "złe". Chodzi o to, że skaluje się liniowo z funkcjonalnościami, ale nie ze zrozumieniem. Kiedy nowy programista musi pracować nad fakturami, musi jednocześnie otworzyć components/, hooks/, utils/, types/ i app/. Funkcjonalność faktur jest rozsiana po pięciu katalogach. Nie ma jednego miejsca, które mówi "tak wygląda fakturowanie".

Obserwowałem, jak czteroosobowy zespół spędził cały sprint na refaktoryzacji do dokładnie takiej struktury, przekonany, że to "czysty" sposób. W ciągu trzech miesięcy wrócili do stanu dezorientacji, tylko z większą liczbą folderów.

Kolokacja oparta na funkcjonalnościach wygrywa. Za każdym razem.#

Wzorzec, który faktycznie przetrwał kontakt z prawdziwym zespołem, to kolokacja oparta na funkcjonalnościach. Idea jest prosta: wszystko, co jest związane z daną funkcjonalnością, żyje razem.

Nie "wszystkie komponenty w jednym folderze, a wszystkie hooki w innym". Odwrotnie. Wszystkie komponenty, hooki, typy i narzędzia do fakturowania żyją w features/invoicing/. Wszystko do uwierzytelniania żyje w 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 };
}

Zwróć uwagę na importy. Wszystkie są relatywne, wszystkie w obrębie danej funkcjonalności. Ten hook nie sięga do jakiegoś globalnego folderu hooks/ ani do współdzielonego utils/format.ts. Używa własnej logiki formatowania z modułu fakturowania, ponieważ formatowanie numeru faktury to sprawa fakturowania, a nie sprawa globalna.

Nieintuicyjna część: to oznacza, że będziesz mieć trochę duplikacji. Twoja funkcjonalność fakturowania może mieć format.ts i twoja funkcjonalność rozliczeń może też mieć format.ts. To jest w porządku. To jest właściwie lepsze niż współdzielony formatters.ts, od którego zależy 14 funkcjonalności i którego nikt nie może bezpiecznie zmienić.

Opierałem się temu przez długi czas. DRY było ewangelią. Ale widziałem, jak współdzielone pliki narzędziowe stają się najniebezpieczniejszymi plikami w bazie kodu — plikami, w których "mała poprawka" dla jednej funkcjonalności psuje trzy inne. Kolokacja z kontrolowaną duplikacją jest łatwiejsza w utrzymaniu niż wymuszone współdzielenie.

Struktura folderów, której faktycznie używam#

Oto pełne drzewko. Zostało przetestowane w boju w dwóch aplikacjach produkcyjnych z zespołami 4-8 osobowymi:

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

Kilka rzeczy do zauważenia:

  • app/ zawiera tylko logikę routingu. Pliki stron są cienkie. Importują z features/ i komponują. Plik strony powinien mieć 20-40 linii, maksymalnie.
  • features/ to miejsce, gdzie żyje prawdziwy kod. Każda funkcjonalność jest samodzielna. Możesz usunąć features/invoicing/ i nic innego się nie zepsuje (poza stronami, które z tego importują, co jest dokładnie takim sprzężeniem, jakiego chcesz).
  • shared/ nie jest śmietnikiem. Ma ścisłe podkategorie. Więcej o tym poniżej.

Grupy tras: więcej niż tylko organizacja#

Grupy tras, te foldery (w nawiasach), to jedna z najbardziej niedocenianych funkcji App Routera. Nie wpływają w ogóle na URL. (dashboard)/invoices/page.tsx renderuje się pod /invoices, nie pod /dashboard/invoices.

Ale prawdziwa siła tkwi w izolacji layoutów. Każda grupa tras dostaje własny 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 />
    </>
  );
}

Sprawdzanie uwierzytelniania odbywa się raz na poziomie layoutu, nie na każdej stronie. Publiczne strony dostają zupełnie inną powłokę. Strony uwierzytelniania nie mają żadnego layoutu — tylko wycentrowaną kartę. To jest czyste, jawne i trudne do zepsucia.

Widziałem zespoły, które umieszczały sprawdzanie uwierzytelniania w middleware, w poszczególnych stronach, w komponentach — wszystko jednocześnie. Wybierz jedno miejsce. Dla większości aplikacji layout grupy tras jest tym miejscem.

Folder shared/ to nie utils/#

Najszybszym sposobem na stworzenie koszmaru utrzymaniowego jest plik lib/utils.ts. Zaczyna się mały. Potem staje się szufladą na śmieci bazy kodu — miejscem, gdzie ląduje każda funkcja, która nie ma oczywistego domu.

Oto moja zasada: folder shared/ wymaga uzasadnienia. Coś trafia do shared/ tylko wtedy, gdy:

  1. Jest używane przez 3+ funkcjonalności, i
  2. Jest naprawdę generyczne (nie logika biznesowa w przebraniu generycznym)

Funkcja formatowania daty, która formatuje terminy płatności faktur? Trafia do features/invoicing/utils/. Funkcja formatowania daty, która przyjmuje Date i zwraca string lokalizacyjny? Ta może trafić do shared/.

Podfolder shared/lib/ jest specjalnie przeznaczony do integracji z usługami zewnętrznymi i infrastrukturą: klienty baz danych, biblioteki uwierzytelniania, dostawcy płatności. To rzeczy, od których zależy cała aplikacja, ale żadna pojedyncza funkcjonalność nie jest ich właścicielem.

typescript
// shared/lib/db.ts — to należy do 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 — to NIE należy do 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}`;
}

Jeśli sięgasz po shared/utils/ i nie potrafisz nazwać pliku czymś bardziej konkretnym niż helpers.ts, zatrzymaj się. Ten kod prawdopodobnie należy do jakiejś funkcjonalności.

Komponenty serwerowe vs. klienckie: wyznacz granicę świadomie#

Oto wzorzec, który stale widzę w bazach kodu z problemami wydajnościowymi:

typescript
// To wygląda rozsądnie, ale jest błędem
"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>
  );
}

Strona jest oznaczona "use client". Teraz wszystko, co importuje — InvoiceTable, InvoiceFilters, hook, każda zależność każdej zależności — trafia do bundla klienta. Twoja 200-wierszowa tabela, która mogła być wyrenderowana na serwerze z zerowym JavaScript, jest teraz wysyłana do przeglądarki.

Rozwiązaniem jest zepchnięcie "use client" w dół, do komponentów liści, które faktycznie tego potrzebują:

typescript
// app/(dashboard)/invoices/page.tsx — Komponent serwerowy (bez dyrektywy)
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 />          {/* Komponent kliencki — ma interaktywne filtry */}
      <InvoiceTable data={invoices} /> {/* Komponent serwerowy — po prostu renderuje wiersze */}
    </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>
  );
}

Zasada, której przestrzegam: każdy komponent jest komponentem serwerowym, dopóki nie potrzebuje useState, useEffect, handlerów zdarzeń lub API przeglądarki. Wtedy, i tylko wtedy, dodaj "use client", i postaraj się, żeby ten komponent był jak najmniejszy.

Wzorzec children jest tutaj twoim najlepszym przyjacielem. Komponent kliencki może otrzymywać komponenty serwerowe jako 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 mogą być komponentami serwerowymi */}
    </div>
  );
}

W ten sposób utrzymujesz 90% drzewa komponentów na serwerze, zachowując jednocześnie interaktywność tam, gdzie ma to znaczenie.

Barrel files: cichy zabójca builda#

Kiedyś umieszczałem index.ts w każdym folderze. Czyste importy, prawda?

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

Wtedy mogłem importować w ten sposób:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

Ładnie wygląda. Oto problem: kiedy bundler widzi ten import, musi zewaluować cały barrel file, żeby ustalić, czym jest InvoiceTable. To znaczy, że ładuje też InvoiceRow, InvoiceForm i InvoicePdf, nawet jeśli potrzebowałeś tylko tabeli.

W jednym projekcie usunięcie wewnętrznych barrel files zmniejszyło liczbę modułów z 11 000 do 3 500 podczas developmentu. Serwer deweloperski przeszedł z 5-10 sekundowych ładowań stron do poniżej 2 sekund. JavaScript przy pierwszym ładowaniu spadł z ponad megabajta do około 200 KB.

Vercel stworzył optimizePackageImports w next.config.ts, żeby obsłużyć to dla paczek zewnętrznych z node_modules. Ale to nie naprawia twoich własnych barrel files. Dla twojego własnego kodu odpowiedź jest prostsza: nie używaj ich.

typescript
// Rób tak
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Nie tak
import { InvoiceTable } from "@/features/invoicing/components";

Tak, ścieżka importu jest dłuższa. Twój build jest szybszy, twoje bundle są mniejsze i twój serwer deweloperski się nie dławi. To wymiana, na którą pójdę za każdym razem.

Jedyny wyjątek: publiczne API funkcjonalności. Jeśli features/invoicing/ udostępnia pojedynczy index.ts na poziomie głównym funkcjonalności z zaledwie 2-3 rzeczami, które inne funkcjonalności mogą importować, to jest w porządku. Działa jako granica, nie jako skrót wygody. Ale barrel files wewnątrz podfolderów jak components/index.ts czy hooks/index.ts? Usuń je.

Cienkie strony, grube funkcjonalności#

Najważniejsza zasada architektoniczna, której przestrzegam, to: pliki stron to okablowanie, nie logika.

Plik strony w app/ powinien robić trzy rzeczy:

  1. Pobierać dane (jeśli to komponent serwerowy)
  2. Importować komponenty funkcjonalności
  3. Komponować je razem

To wszystko. Żadnej logiki biznesowej. Żadnego złożonego warunkowego renderowania. Żadnych 200-liniowych drzew JSX. Jeśli plik strony rośnie powyżej 40 linii, umieszczasz kod funkcjonalności w złym miejscu.

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} />;
}

Dwanaście linii. Strona pobiera potrzebne dane, obsługuje przypadek not-found i deleguje całą resztę do funkcjonalności. Jeśli UI szczegółów faktury się zmienia, edytujesz features/invoicing/, nie app/. Jeśli zmienia się trasa, przenosisz plik strony, a kod funkcjonalności się tym nie przejmuje.

W ten sposób uzyskujesz bazę kodu, w której 15 programistów może pracować jednocześnie, nie wchodząc sobie w drogę.

Niewygodna prawda#

Nie ma struktury folderów, która zapobiega złemu kodowi. Widziałem pięknie zorganizowane projekty pełne okropnych abstrakcji i chaotyczne płaskie struktury, w których łatwo się pracowało, ponieważ sam kod był przejrzysty.

Struktura to narzędzie, nie cel. Podejście oparte na funkcjonalnościach, które tu opisałem, działa, ponieważ optymalizuje pod kątem tego, co ma największe znaczenie na dużą skalę: czy programista, który nigdy nie widział tej bazy kodu, może znaleźć to, czego potrzebuje, i zmienić to bez psucia czegoś innego?

Jeśli twoja obecna struktura odpowiada "tak" na to pytanie, nie refaktoryzuj jej, bo post na blogu ci tak kazał — nawet ten.

Ale jeśli twoi programiści spędzają więcej czasu na nawigowaniu po bazie kodu niż na pisaniu kodu, jeśli "gdzie to powinno trafić?" to powtarzające się pytanie w PR-ach, jeśli zmiana w rozliczeniach w jakiś sposób psuje dashboard — to twoja struktura walczy z tobą. A wzorce powyżej to sposób, w jaki to naprawiałem, za każdym razem.

Powiązane wpisy