Retour au blog

Comment je structure un projet Next.js après 3 ans

Des composants partout, des utils mélangés avec des hooks, des routes qui importent depuis des dossiers random. Après des dizaines de projets, voici la structure qui scale vraiment.

13 février 202612 min de lecture
Next.jsArchitectureTypeScriptProject Structure
Comment je structure un projet Next.js après 3 ans
Le Chaos des Dossiers - Du bordel dans app/ à la séparation des responsabilités
Le Chaos des Dossiers - Du bordel dans app/ à la séparation des responsabilités

Mon premier projet Next.js était un bordel.

Des composants partout. Des utils mélangés avec des hooks. Des routes API qui importaient depuis des dossiers random. Chaque fois que je devais trouver quelque chose, je passais 5 minutes à chercher.

Trois ans plus tard, j'ai travaillé sur des dizaines de projets Next.js. Certains en solo, certains avec des équipes de 10+. J'ai essayé toutes les structures que j'ai trouvées sur internet. La plupart ont échoué quand le projet a grandi.

Voici ce qui marche vraiment.


La structure que j'utilise aujourd'hui

src/
├── app/                    # App Router (routes uniquement)
│   ├── (auth)/
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (main)/
│   │   ├── dashboard/
│   │   │   ├── page.tsx
│   │   │   └── loading.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── route.ts
│   ├── layout.tsx
│   └── page.tsx
│
├── components/
│   ├── ui/                 # Design system (boutons, inputs, cards)
│   │   ├── button.tsx
│   │   ├── input.tsx
│   │   └── card.tsx
│   └── features/           # Composants spécifiques aux features
│       ├── auth/
│       │   ├── login-form.tsx
│       │   └── register-form.tsx
│       └── dashboard/
│           ├── stats-card.tsx
│           └── recent-activity.tsx
│
├── lib/                    # Utilitaires core
│   ├── db.ts              # Client base de données
│   ├── auth.ts            # Configuration auth
│   └── utils.ts           # Helpers génériques
│
├── hooks/                  # Hooks React custom
│   ├── use-user.ts
│   └── use-media-query.ts
│
├── services/               # Logique métier & appels API
│   ├── user.service.ts
│   └── payment.service.ts
│
├── types/                  # Types TypeScript
│   └── index.ts
│
└── config/                 # Configuration app
    └── site.ts

Laisse-moi t'expliquer pourquoi chaque décision compte.


Leçon 1 : garde app/ pour le routing uniquement

Le dossier app/ est pour le routing. C'est tout.

Avant, je mettais des composants, des utils, et toutes sortes de trucs dans app/. Grosse erreur. Le dossier devenait impossible à naviguer.

# ❌ Ce que je faisais avant
app/
├── dashboard/
│   ├── page.tsx
│   ├── DashboardHeader.tsx
│   ├── DashboardSidebar.tsx
│   ├── useDashboardData.ts
│   └── dashboard.utils.ts
# ✅ Ce que je fais maintenant
app/
├── dashboard/
│   ├── page.tsx
│   └── loading.tsx

components/features/dashboard/
├── dashboard-header.tsx
├── dashboard-sidebar.tsx
└── stats-card.tsx

hooks/
└── use-dashboard-data.ts

Le fichier page importe ce dont il a besoin. La structure de routing reste propre.


Leçon 2 : les route groups changent tout

Les route groups (dossiers avec parenthèses) permettent d'organiser les routes sans affecter les URLs.

app/
├── (auth)/           # URL: /login, /register
│   ├── login/
│   └── register/
├── (main)/           # URL: /dashboard, /settings
│   ├── dashboard/
│   └── settings/
└── (marketing)/      # URL: /, /pricing, /about
    ├── page.tsx
    ├── pricing/
    └── about/

Chaque groupe peut avoir son propre layout :

// app/(auth)/layout.tsx
export default function AuthLayout({ children }) {
  return (
    <div className="min-h-screen flex items-center justify-center">
      {children}
    </div>
  );
}

// app/(main)/layout.tsx
export default function MainLayout({ children }) {
  return (
    <div className="flex">
      <Sidebar />
      <main className="flex-1">{children}</main>
    </div>
  );
}

Plus de layouts conditionnels dans un seul fichier. Chaque section a sa propre structure.


Leçon 3 : sépare les composants UI des composants feature

Ça m'a pris des années à comprendre.

Les composants UI sont génériques, réutilisables. Boutons, inputs, cards, modals. Ils ne connaissent pas ta logique métier.

Les composants feature sont spécifiques à ton app. Ils connaissent les utilisateurs, les paiements, les dashboards. Ils utilisent les composants UI.

components/
├── ui/                     # Générique, réutilisable
│   ├── button.tsx         # <Button variant="primary">
│   ├── input.tsx          # <Input label="Email">
│   ├── card.tsx           # <Card title="Stats">
│   └── modal.tsx          # <Modal open={true}>
│
└── features/               # Spécifique à l'app
    ├── auth/
    │   ├── login-form.tsx # Utilise <Button>, <Input>
    │   └── user-avatar.tsx
    └── billing/
        ├── pricing-card.tsx
        └── payment-form.tsx

Quand j'ai besoin d'un bouton, je vais dans ui/. Quand j'ai besoin du formulaire de login, je vais dans features/auth/.

Plus besoin de chercher parmi 50 composants.


Leçon 4 : les services gèrent la logique métier

Avant, je mettais des appels API partout. Dans les composants. Dans les hooks. Dans les utils.

Maintenant tout passe par les services :

// services/user.service.ts
export const userService = {
  async getById(id: string) {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  },

  async update(id: string, data: UpdateUserData) {
    const response = await fetch(`/api/users/${id}`, {
      method: 'PATCH',
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error('Failed to update user');
    return response.json();
  },

  async delete(id: string) {
    const response = await fetch(`/api/users/${id}`, {
      method: 'DELETE',
    });
    if (!response.ok) throw new Error('Failed to delete user');
  },
};

Les composants utilisent les services, ils ne connaissent pas fetch :

// components/features/settings/profile-form.tsx
import { userService } from '@/services/user.service';

function ProfileForm({ userId }) {
  async function handleSubmit(data) {
    await userService.update(userId, data);
  }
  // ...
}

Avantages :

  • La logique API est à un seul endroit
  • Facile à tester (mock le service)
  • Facile à changer (remplace fetch par axios, ajoute du cache)

Leçon 5 : le dossier lib/ est pour l'infrastructure

lib/ contient ce dont ton app a besoin pour tourner. Connexions base de données. Setup auth. Clients de services externes.

// lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const db = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
// lib/auth.ts
import NextAuth from 'next-auth';
import { authConfig } from '@/config/auth';

export const { auth, signIn, signOut } = NextAuth(authConfig);
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

lib/ est bas niveau. Les services et composants importent depuis lib/, jamais l'inverse.


Leçon 6 : colocalise quand ça a du sens

Parfois un composant n'est utilisé qu'à un seul endroit. Ne crée pas un dossier pour lui.

app/
├── dashboard/
│   ├── page.tsx
│   └── _components/       # Utilisé uniquement dans dashboard
│       └── welcome-banner.tsx

Le préfixe underscore (_components) dit à Next.js de l'ignorer pour le routing. Il reste proche de là où il est utilisé.

J'utilise ça pour :

  • Les composants utilisés par une seule page
  • Les hooks spécifiques à une page
  • Les types locaux

Si j'en ai besoin ailleurs plus tard, je le déplace dans components/features/.


Leçon 7 : les imports absolus sauvent la santé mentale

Plus de ../../../../components/ui/button.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Maintenant :

import { Button } from '@/components/ui/button';
import { userService } from '@/services/user.service';
import { useUser } from '@/hooks/use-user';

Propre. Clair. Pas de calcul mental pour trouver le chemin.


Ce que j'ai arrêté de faire

Organiser par type (la structure "classique")

# ❌ Ne fais pas ça
components/
├── Header.tsx
├── Footer.tsx
├── Button.tsx
├── LoginForm.tsx
├── DashboardStats.tsx
├── UserAvatar.tsx
└── ... 50 autres fichiers

Ça ne scale pas. À 50 composants, t'es perdu.

Tout mettre dans utils/

# ❌ Ne fais pas ça
utils/
├── api.ts
├── auth.ts
├── db.ts
├── format.ts
├── validation.ts
└── ... tout le reste

utils/ devient un tiroir fourre-tout. Sépare-le en lib/, services/, et helpers/.

Over-engineer dès le jour 1

J'ai vu des projets avec 20 dossiers et 5 fichiers. Ne fais pas ça.

Commence simple. Ajoute de la structure quand tu ressens la douleur. Si tu cherches des fichiers, tu as besoin de mieux organiser. Sinon, t'es bien.


L'arbre de décision


La cheat sheet

QuoiExemple
Pages & routesapp/app/dashboard/page.tsx
UI génériquecomponents/ui/button.tsx, input.tsx
Composants featurecomponents/features/login-form.tsx
Hooks customhooks/use-user.ts
Appels API & logiqueservices/user.service.ts
DB, auth, utilslib/db.ts, auth.ts
Types TypeScripttypes/index.ts
Config appconfig/site.ts

La leçon

La structure c'est pas suivre des règles. C'est trouver les choses vite.

Quand j'ouvre un projet après 6 mois, je dois savoir où tout est. Quand un nouveau dev rejoint l'équipe, il devrait pas avoir besoin d'une visite guidée.

La meilleure structure est celle qui te fait moins réfléchir.


C'est la partie 3 de ma série "Ce que j'ai appris à mes dépens". Prochain article : ce que 3 ans de code reviews m'ont appris.

Lire la partie 4 : Ce que 3 ans de code reviews m'ont appris →


Des questions ? Contacte-moi sur LinkedIn ou découvre plus sur mon blog.