Aller au contenu
·12 min de lecture

Structurer des projets Next.js a grande échelle : ce que j'ai appris a mes depens

Des leçons durement acquises sur l'organisation de codebases Next.js qui ne s'effondrent pas sous leur propre poids. Architecture basee sur les features, route groups, frontieres serveur/client, pieges des barrel files, et une vraie structure de dossiers.

Partager:X / TwitterLinkedIn

Chaque projet Next.js demarre proprement. Quelques pages, un dossier components, peut-être un lib/utils.ts. On se sent productif. On se sent organisé.

Puis six mois passent. Trois développeurs rejoignent l'équipe. Le dossier components contient 140 fichiers. Quelqu'un a créé utils2.ts parce que utils.ts faisait 800 lignes. Personne ne sait quel layout enveloppe quelle route. Votre build prend quatre minutes et vous ne savez pas pourquoi.

J'ai traverse ce cycle plusieurs fois. Ce qui suit, c'est ce que je fais réellement maintenant, après avoir appris la plupart de ces leçons en livrant d'abord la mauvaise chose.

La structure par défaut s'effondre rapidement#

La documentation de Next.js suggere une structure de projet. Elle est correcte. Elle fonctionne pour un blog personnel ou un petit outil. Mais des que plus de deux développeurs touchent la même codebase, l'approche "par type" commencé a craquer :

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     <- le debut de la fin
  app/
    page.tsx
    dashboard/
      page.tsx

Le problème n'est pas que ce soit "faux". C'est que ca evolue lineairement avec les features mais pas avec la comprehension. Quand un nouveau développeur doit travailler sur la facturation, il doit ouvrir components/, hooks/, utils/, types/ et app/ simultanement. La feature facturation est eparpillee dans cinq répertoires. Il n'y a aucun endroit unique qui dit "voici a quoi ressemble la facturation".

J'ai regarde une équipe de quatre développeurs passer un sprint entier a refactorer vers exactement cette structure, convaincus que c'etait la manière "propre". En trois mois, ils etaient de nouveau perdus, juste avec plus de dossiers.

La colocation par feature gagne. A chaque fois.#

Le pattern qui a réellement survecu au contact d'une vraie équipe est la colocation par feature. L'idee est simple : tout ce qui est lie a une feature vit ensemble.

Pas "tous les composants dans un dossier et tous les hooks dans un autre". L'inverse. Tous les composants, hooks, types et utilitaires pour la facturation vivent dans features/invoicing/. Tout ce qui concerne l'auth vit dans 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 };
}

Remarquez les imports. Ils sont tous relatifs, tous au sein de la feature. Ce hook ne va pas chercher dans un dossier global hooks/ ou un utils/format.ts partage. Il utilisé la propre logique de formatage de la feature facturation, parce que le formatage des numeros de facture est une responsabilite de la facturation, pas une responsabilite globale.

La partie contre-intuitive : cela signifie que vous aurez de la duplication. Votre feature facturation pourrait avoir un format.ts et votre feature facturation (billing) pourrait aussi en avoir un. C'est normal. C'est en fait mieux qu'un formatters.ts partage dont 14 features dependent et que personne ne peut modifier en toute sécurité.

J'ai resiste a ca pendant longtemps. Le DRY etait un dogme. Mais j'ai vu des fichiers utilitaires partages devenir les fichiers les plus dangereux d'une codebase, des fichiers ou une "petite correction" pour une feature en casse trois autres. La colocation avec une duplication controlee est plus maintenable que le partage force.

La structure de dossiers que j'utilisé réellement#

Voici l'arborescence complete. Elle a été eprouvee en production sur deux applications avec des équipes de 4 a 8 personnes :

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

Quelques points a remarquer :

  • app/ ne contient que la logique de routage. Les fichiers page sont minces. Ils importent depuis features/ et composent. Un fichier page devrait faire 20-40 lignes, maximum.
  • features/ est la ou vit le vrai code. Chaque feature est autonome. Vous pouvez supprimer features/invoicing/ et rien d'autre ne casse (sauf les pages qui importent depuis ce dossier, ce qui est exactement le couplage que vous voulez).
  • shared/ n'est pas un fourre-tout. Il a des sous-categories strictes. Plus de details ci-dessous.

Route Groups : bien plus que de l'organisation#

Les route groups, les dossiers (entre parentheses), sont l'une des fonctionnalités les plus sous-utilisees de l'App Router. Ils n'affectent pas du tout l'URL. (dashboard)/invoices/page.tsx s'affiche a /invoices, pas a /dashboard/invoices.

Mais le vrai pouvoir, c'est l'isolation des layouts. Chaque route group a son propre 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 />
    </>
  );
}

La verification d'authentification se fait une seule fois au niveau du layout, pas dans chaque page. Les pages publiques obtiennent un shell completement différent. Les pages d'auth n'ont aucun layout du tout, juste une carte centree. C'est propre, explicite et difficile a casser.

J'ai vu des équipes mettre des verifications d'auth dans le middleware, dans les pages individuelles, dans les composants, tout en même temps. Choisissez un seul endroit. Pour la plupart des applications, le layout du route group est cet endroit.

Le dossier shared/ n'est pas utils/#

Le moyen le plus rapide de creer un cauchemar de maintenance est un fichier lib/utils.ts. Ca commencé petit. Puis ca devient le tiroir a bazar de la codebase, ou chaque fonction qui n'a pas de place évidente finit par atterrir.

Voici ma regle : le dossier shared/ exige une justification. Quelque chose va dans shared/ uniquement si :

  1. C'est utilisé par 3+ features, et
  2. C'est véritablement generique (pas de la logique metier deguisee en quelque chose de generique)

Une fonction de formatage de date qui formate les dates d'echeance de factures ? Ca va dans features/invoicing/utils/. Une fonction de formatage de date qui prend un Date et retourne une chaine localisee ? Ca peut aller dans shared/.

Le sous-dossier shared/lib/ est specifiquement destine aux integrations tierces et a l'infrastructure : clients de base de donnees, bibliotheques d'auth, fournisseurs de paiement. Ce sont des choses dont toute l'application depend mais qu'aucune feature ne possede en propre.

typescript
// shared/lib/db.ts — ceci appartient a 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 — ceci N'appartient PAS a 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}`;
}

Si vous etes sur le point d'utiliser shared/utils/ et que vous ne pouvez pas nommer le fichier de manière plus spécifique que helpers.ts, arretez-vous. Ce code appartient probablement a une feature.

Composants serveur vs. client : tracez la ligne deliberement#

Voici un pattern que je vois constamment dans les codebases qui ont des problèmes de performance :

typescript
// Ca a l'air raisonnable mais c'est une erreur
"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>
  );
}

La page est marquee "use client". Maintenant tout ce qu'elle importe, InvoiceTable, InvoiceFilters, le hook, chaque dépendance de chaque dépendance, se retrouve dans le bundle client. Votre tableau de 200 lignes qui aurait pu être rendu côté serveur avec zero JavaScript est maintenant envoye au navigateur.

La solution est de pousser "use client" vers les composants feuilles qui en ont réellement besoin :

typescript
// app/(dashboard)/invoices/page.tsx — Composant serveur (pas de directive)
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 />          {/* Composant client — a des filtres interactifs */}
      <InvoiceTable data={invoices} /> {/* Composant serveur — affiche juste des lignes */}
    </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>
  );
}

La regle que je suis : chaque composant est un composant serveur jusqu'a ce qu'il ait besoin de useState, useEffect, de gestionnaires d'événements ou d'API navigateur. Alors, et seulement alors, ajoutez "use client", et essayez de rendre ce composant aussi petit que possible.

Le pattern children est votre meilleur allie ici. Un composant client peut recevoir des composants serveur en tant que 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 peut etre des composants serveur */}
    </div>
  );
}

C'est comme ca que vous gardez 90% de votre arbre de composants sur le serveur tout en ayant de l'interactivite la ou c'est nécessaire.

Barrel files : le tueur silencieux du build#

Avant, je mettais un index.ts dans chaque dossier. Des imports propres, non ?

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

Ensuite je pouvais importer comme ca :

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

Ca a l'air elegant. Voici le problème : quand le bundler voit cet import, il doit évaluer l'integalite du barrel file pour determiner ce qu'est InvoiceTable. Ca signifie qu'il charge aussi InvoiceRow, InvoiceForm et InvoicePdf, même si vous n'aviez besoin que du tableau.

Dans un projet, la suppression des barrel files internes a fait passer le nombre de modules de 11 000 a 3 500 en développement. Le serveur de dev est passé de 5-10 secondes de chargement par page a moins de 2 secondes. Le JavaScript au premier chargement est passé de plus d'un megaoctet a environ 200 Ko.

Vercel a construit optimizePackageImports dans next.config.ts pour gérer ca pour les packages tiers de node_modules. Mais ca ne corrige pas vos propres barrel files. Pour votre propre code, la réponse est plus simple : ne les utilisez pas.

typescript
// Faites ceci
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Pas ceci
import { InvoiceTable } from "@/features/invoicing/components";

Oui, le chemin d'import est plus long. Votre build est plus rapide, vos bundles sont plus petits, et votre serveur de dev ne s'etouffe pas. C'est un compromis que j'accepte a chaque fois.

La seule exception : l'API publique d'une feature. Si features/invoicing/ expose un seul index.ts a la racine de la feature avec juste les 2-3 choses que les autres features sont autorisees a importer, c'est correct. Ca agit comme une frontiere, pas un raccourci de confort. Mais les barrel files a l'interieur des sous-dossiers comme components/index.ts ou hooks/index.ts ? Supprimez-les.

Pages minces, features epaisses#

La regle architecturale la plus importante que je suis est celle-ci : les fichiers page sont du cablage, pas de la logique.

Un fichier page dans app/ devrait faire trois choses :

  1. Récupérer les donnees (si c'est un composant serveur)
  2. Importer les composants de features
  3. Les composer ensemble

C'est tout. Pas de logique metier. Pas de rendu conditionnel complexe. Pas d'arbres JSX de 200 lignes. Si un fichier page depasse 40 lignes, vous mettez du code de feature au mauvais endroit.

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

Douze lignes. La page récupéré les donnees dont elle a besoin, gere le cas "non trouve", et delegue tout le reste a la feature. Si l'interface du detail de facture change, vous modifiez features/invoicing/, pas app/. Si la route change, vous deplacez le fichier page, et le code de la feature s'en fiche.

C'est comme ca que vous obtenez une codebase ou 15 développeurs peuvent travailler simultanement sans se marcher dessus.

La vérité qui derange#

Il n'existe pas de structure de dossiers qui empeche le mauvais code. J'ai vu des projets magnifiquement organises remplis d'abstractions terribles, et des structures plates desordonnees qui etaient faciles a travailler parce que le code lui-même etait clair.

La structure est un outil, pas un objectif. L'approche basee sur les features que j'ai decrite ici fonctionne parce qu'elle optimise pour la chose qui compte le plus a grande échelle : est-ce qu'un développeur qui n'a jamais vu cette codebase peut trouver ce dont il a besoin et le modifier sans casser autre chose ?

Si votre structure actuelle repond "oui" a cette question, ne la refactorez pas parce qu'un article de blog vous l'a dit, même celui-ci.

Mais si vos développeurs passent plus de temps a naviguer dans la codebase qu'a écrire du code, si "ou est-ce que ca va ?" est une question récurrente dans les PRs, si une modification de la facturation casse mysterieusement le tableau de bord, alors votre structure vous combat. Et les patterns ci-dessus sont la manière dont j'ai resolu ca, a chaque fois.

Articles similaires