Přeskočit na obsah
·10 min čtení

Strukturovani Next.js projektu ve velkem měřítku: Co jsem se naučil na vlastni kuzi

Těžce získané lekce o organizaci Next.js codebasu, které se nezhroutia pod vlastni vahou. Architektura založena na features, route groups, hranice server/client, pasti barrel souborů a reálná struktura složek.

Sdílet:X / TwitterLinkedIn

Každý Next.js projekt začíná cistym. Par stranek, složka components, možná lib/utils.ts. Citime se produktivni. Citime se organizovani.

Pak uplyne šest mesicu. Pripoji se tri vývojáři. Složka components ma 140 souborů. Někdo vytvoril utils2.ts, protoze utils.ts mel 800 řádku. Nikdo nevi, který layout obaluje kterou routu. Build trva ctyri minuty a nejste si jisti proč.

Timto cyklem jsem prosel vicekrat. To, co nasleduje, je to, co skutečně dělám ted, pote co jsem se vetsinu techto lekci naučil tim, ze jsem nejdriv dodal spatnou věc.

Vychozi struktura se rychle rozpadne#

Dokumentace Next.js doporucuje strukturu projektu. Je v poradku. Funguje pro osobni blog nebo maly nástroj. Ale v momente, kdy se na stejnem codebasu podili vice nez dva vývojáři, přístup "podle typu" začíná praskat:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 dalsich souboru
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     <- zacatek konce
  app/
    page.tsx
    dashboard/
      page.tsx

Problem neni v tom, ze je to "špatně." Problem je, ze to skaluje linearni s features, ale ne s porozumenim. Kdyz novy vývojář potrebuje pracovat na fakturach, musi soucasne otevrit components/, hooks/, utils/, types/ a app/. Feature fakturace je roztrousena přes pet adresaru. Neexistuje jedno místo, které by rikalo "takhle vypada fakturace."

Sledoval jsem tym ctyr vyvojaru, jak stravili cely sprint refaktorovanim do přesně teto struktury, presvedceni ze je to ten "čistý" způsob. Behem tri mesicu byli zpet ve zmatku, jen s vice slozkami.

Kolokace podle features vyhrava. Pokaždé.#

Vzor, který skutečně prezil kontakt s realnym tymem, je kolokace podle features. Myslenka je jednoduchá: vse, co souvisi s feature, zije pohromade.

Ne "všechny komponenty v jedne slozce a všechny hooky v druhe." Přesný opak. Všechny komponenty, hooky, typy a utility pro fakturaci ziji uvnitr features/invoicing/. Vse pro autentifikaci zije uvnitr 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 };
}

Vsimete si importu. Všechny jsou relativni, všechny v ramci feature. Tento hook nesaha do nějaké globalni složky hooks/ nebo sdileneho utils/format.ts. Pouziva vlastni formatovaci logiku feature fakturace, protoze formatovani čísla faktury je zalezitost fakturace, ne globalni zalezitost.

Neintuitivni část: to znamena, ze budete mit urcitou duplicitu. Vase feature fakturace může mit format.ts a vase feature ucetnictvi může take mit format.ts. To je v poradku. Je to ve skutecnosti lepsi nez sdileny formatters.ts, na kterem zavisi 14 features a nikdo ho nemuze bezpecne zmenit.

Dlouho jsem se tomu branil. DRY bylo evangelium. Ale videl jsem, jak se sdilene utility soubory stavaji nejnebezpecnejsimi soubory v codebasu — soubory, kde "mala oprava" pro jednu feature rozbije tri další. Kolokace s kontrolovanou duplicitou je udrzovatelnejsi nez vynucene sdileni.

Struktura složek, kterou skutečně používám#

Zde je kompletni strom. Byl otestovan v boji ve dvou produkcnich aplikacich s tymy 4-8 lidi:

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

Par věci, kterých si můžete vsimnout:

  • app/ obsahuje pouze logiku routovani. Soubory stranek jsou tenke. Importuji z features/ a skladaji. Soubor stránky by mel mit 20-40 řádku, maximalne.
  • features/ je místo, kde zije skutecny kod. Každá feature je samostatna. Můžete smazat features/invoicing/ a nic dalsiho se nerozbije (krome stranek, které z ni importuji, coz je přesně ta vazba, kterou chcete).
  • shared/ neni odpadkovy kos. Ma striktni podkategorie. Vice o tom nize.

Route Groups: Vic nez jen organizace#

Route groups — složky v (zavorkach) — jsou jednou z nejvice nedostatecne vyuzivanych features v App Routeru. Vubec neovlivnuji URL. (dashboard)/invoices/page.tsx se renderuje na /invoices, ne na /dashboard/invoices.

Ale skutecna sila je izolace layoutu. Každá route group dostane svuj vlastni 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 />
    </>
  );
}

Kontrola autentifikace probehne jednou na urovni layoutu, ne na každé strance. Verejne stránky dostanou uplne jiný obal. Autentifikační stránky nemaji žádný layout — jen vycentrovanou kartu. Je to čisté, explicitni a tezko se to pokazi.

Videl jsem tymy, jak davaji kontroly autentifikace do middleware, do jednotlivych stranek, do komponent — všechno zaroven. Vyberte si jedno místo. Pro vetsinu aplikaci je route group layout tim mistem.

Složka shared/ neni utils/#

Nejrychlejsi způsob, jak vytvorit nocni muru na udrzbu, je soubor lib/utils.ts. Začíná maly. Pak se z nej stane supliky na vseho v codebasu — místo, kde konci každá funkce, která nema zjevny domov.

Moje pravidlo: složka shared/ vyzaduje zduvodneni. Něco jde do shared/ pouze pokud:

  1. Pouzivaji to 3+ features, a
  2. Je to skutečně genericke (ne business logika v generickem prevleku)

Funkce pro formatovani dat, která formatuje data splatnosti faktur? Ta patri do features/invoicing/utils/. Funkce pro formatovani dat, která prijme Date a vrati lokalizovany řetězec? Ta může jit do shared/.

Podslozka shared/lib/ je konkretne pro integrace treti stran a infrastrukturu: databazove klienty, autentifikační knihovny, poskytovatele plateb. Jsou to věci, na kterých zavisi cela aplikace, ale žádná jednotliva feature je nevlastni.

typescript
// shared/lib/db.ts — toto patri 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 — toto NEpatri 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}`;
}

Pokud sahate po shared/utils/ a nemuzete soubor pojmenovat konkretneji nez helpers.ts, zastavte se. Ten kod pravdepodobne patri dovnitr feature.

Server vs. Client komponenty: Hranici nakreslete vedome#

Tady je vzor, který neustale vidim v codebazich s vykonnostnimi problemy:

typescript
// Tohle vypada rozumne, ale je to chyba
"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>
  );
}

Stránka je oznacena "use client". Ted všechno, co importuje — InvoiceTable, InvoiceFilters, hook, každá zavislost každé zavislosti — je v klientskem bundlu. Vase 200-radkova tabulka, která mohla byt vyrenderovana na serveru s nulovym JavaScriptem, se ted odesila do prohlizece.

Oprava je přesunout "use client" dolu ke koncovym komponentam, které to skutečně potřebuji:

typescript
// app/(dashboard)/invoices/page.tsx — Server Component (zadna direktiva)
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 />          {/* Client Component — ma interaktivni filtry */}
      <InvoiceTable data={invoices} /> {/* Server Component — jen renderuje radky */}
    </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>
  );
}

Pravidlo, kterym se ridim: každá komponenta je Server Component, dokud nepotrebuje useState, useEffect, event handlery nebo API prohlizece. Teprve pak pridejte "use client" a snazte se, aby ta komponenta byla co nejmensi.

Vzor children je zde vas nejlepsi pritel. Client Component může prijimat Server Components jako children:

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Prepnout</button>
      {isOpen && children}  {/* children mohou byt Server Components */}
    </div>
  );
}

Takto udrzite 90 % vaseho stromu komponent na serveru a pritom budete mit interaktivitu tam, kde je potřeba.

Barrel soubory: Tichy zabijak buildu#

Drive jsem daval index.ts do každé složky. Čisté importy, ne?

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

Pak jsem mohl importovat takto:

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

Vypada to hezky. Problem je: kdyz bundler uvidi tento import, musi vyhodnotit cely barrel soubor, aby zjistil, co je InvoiceTable. To znamena, ze nacte take InvoiceRow, InvoiceForm a InvoicePdf, i kdyz jste potrebovali jen tabulku.

V jednom projektu odstraneni internich barrel souborů snizilo pocet modulu z 11 000 na 3 500 behem vyvoje. Dev server se dostal z 5-10 sekundoveho nacitani stranek na pod 2 sekundy. First load JavaScript klesl z vice nez megabajtu na přibližně 200 KB.

Vercel vytvoril optimizePackageImports v next.config.ts pro řešení tohoto problemu u balicku treti stran z node_modules. Ale neřeší vase vlastni barrel soubory. Pro vas vlastni kod je odpoved jednodussi: nepouzivejte je.

typescript
// Takhle ano
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Takhle ne
import { InvoiceTable } from "@/features/invoicing/components";

Ano, cesta importu je delsi. Vas build je rychlejsi, vase bundly jsou menší a vas dev server se nedusi. To je vymena, kterou prijmu pokaždé.

Jedina vyjimka: verejne API feature. Pokud features/invoicing/ vystavuje jediný index.ts na korenove urovni feature jen se 2-3 vecmi, které mohou ostatni features importovat, je to v poradku. Funguje to jako hranice, ne jako zkratka pro pohodli. Ale barrel soubory uvnitr podslozek jako components/index.ts nebo hooks/index.ts? Zruste je.

Tenke stránky, tuste features#

Nejdulezitejsi architektonické pravidlo, kterym se ridim, je: soubory stranek jsou propojeni, ne logika.

Soubor stránky v app/ by mel dělat tri věci:

  1. Nacist data (pokud je to Server Component)
  2. Importovat feature komponenty
  3. Slozit je dohromady

To je vse. Žádná business logika. Žádné slozite podminene renderovani. Žádné 200-radkove JSX stromy. Pokud soubor stránky roste přes 40 řádku, davate feature kod na špatně místo.

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

Dvanact řádku. Stránka nacte data, která potrebuje, osetruje případ not-found a vse ostatni deleguje na feature. Pokud se zmeni UI detailu faktury, editujete features/invoicing/, ne app/. Pokud se zmeni routa, presunete soubor stránky a feature kod se o to nestara.

Takto ziskate codebase, kde 15 vyvojaru může pracovat soucasne, aniz by si navzajem stoupali na nohy.

Neprijemna pravda#

Neexistuje struktura složek, která by zabranila spatnemu kodu. Videl jsem krasne organizovane projekty plne hroznych abstrakci a neprehledne ploche struktury, ve kterých se snadno pracovalo, protoze samotny kod byl jasny.

Struktura je nástroj, ne cil. Přístup založený na features, který jsem zde popsal, funguje, protoze optimalizuje pro věc, na které ve velkem měřítku zalezi nejvic: může vývojář, který tento codebase nikdy nevidel, najit to, co potrebuje, a zmenit to, aniz by rozbil něco jineho?

Pokud vase soucasna struktura odpovida na tuto otazku "ano," nerefaktorujte ji jen proto, ze vam to rekl nějaký clanek na blogu — ani tento.

Ale pokud vasi vývojáři travi vic času navigaci v codebasu nez psanim kodu, pokud "kam to patri?" je opakujici se otazka v pull requestech, pokud zmena v ucetnictvi nejakym způsobem rozbije dashboard — pak vase struktura pracuje proti vam. A vzory vyse jsou způsob, jakym jsem to pokaždé opravil.

Související články