Retour au blog
Next.js Architecture Claude Code Développement IA TypeScript Ingénierie SaaS

Comment empêcher l'IA de transformer votre app Next.js en chaos architectural

Par Ingenix Online · Publié le 23 avril 2026
Comment empêcher l'IA de transformer votre app Next.js en chaos architectural

Vous livrez vite. Claude ou Cursor complète des fonctionnalités entières en quelques secondes. Les tests passent. Les PRs fusionnent. Trois mois plus tard, votre codebase Next.js App Router de 73 routes est un enchevêtrement que personne ne veut toucher — et l’IA a contribué à construire chaque ligne.

Ce n’est pas une histoire d’IA qui écrit du mauvais code. C’est une histoire d’IA qui est très douée pour le code et totalement indifférente à l’architecture.

Sans garde-fous, tout assistant de codage IA empruntera le chemin de moindre résistance. Ce chemin ressemble à ça : mettre la logique métier dans les gestionnaires de routes, mélanger du SQL brut avec des appels fetch('/api/...') dans le même composant, se rabattre sur as unknown as quand les types deviennent gênants, et câbler Stripe, Clerk et OpenAI directement dans la fonction censée uniquement gérer le HTTP.

L’IA livre. Vous livrez. Personne ne le remarque jusqu’à ce que ça coûte cher.


Un vrai post-mortem : 14 violations dans 73 fichiers de routes

Ce n’est pas hypothétique. On a audité une codebase SaaS Next.js en production après six mois de développement intensif assisté par IA. Voici ce qu’on a trouvé dans 73 fichiers de routes :

Aucune couche applicative. Les gestionnaires de routes et les layouts de page orchestraient directement les workflows. Les règles métier étaient couplées à NextRequest, NextResponse, Clerk et Stripe — rendant la réutilisation impossible et les tests coûteux.

Boundaries de données mélangées. La même fonctionnalité était accessible via du SQL brut à un endroit, des query helpers à un autre, et des appels fetch() internes depuis des composants client dans un troisième. Pas de boundary cohérente — donc des vérifications de permissions dupliquées et un comportement divergent entre serveur et client.

Un bucket SQL brut, pas une couche repository. Les modules de requêtes retournaient les résultats SELECT * directement comme types de domaine. Les changements de schéma se propageaient partout parce que les types de persistance, d’API et d’UI étaient tous identiques.

Types centralisés mais non protégés. Un énorme src/types/index.ts partagé entre les couches DB, API et UI. La couche de requêtes s’appuyait sur sql<T> et as unknown as pour la “sécurité des types” — ce qui est de la confiance, pas de la vérification.

Validation incohérente. 18 des 73 routes utilisaient le helper d’erreur API partagé. Les 49 autres lisaient request.json() directement et validaient les champs à la main. Des codes de statut différents, des formats d’erreur différents, aucun contrat.

UI couplée au transport. Les pages client connaissaient les URLs des endpoints, les formes des requêtes et les détails de validation. Les changements backend se répercutaient dans des dizaines de composants.

Pages-coquilles géantes. Les pages locales aux routes regroupaient le rendu UI, l’orchestration d’état et la logique métier dans le même fichier.

Pas de ports injectables. La logique de domaine importait openai, Stripe et Redis directement. Chaque test unitaire nécessitait un mock framework. La couverture était minimale pour une grande appli en production.

Raccourcis de sécurité. Une variable d’env MASTER_USER_ID créait un comportement super-utilisateur caché. Le rate limiting se désactivait silencieusement quand Redis était indisponible — fail-open, pas fail-closed.

Requêtes N+1 dans les chemins de requête. Des boucles d’API externes sérielles dans les handlers. Une latence qui croissait linéairement avec la taille du dataset.

Valeurs par défaut produit codées en dur. Les campagnes d’onboarding et les poids d’expérience vivaient dans des fonctions de repository — une politique soudée aux chemins d’écriture DB.

Effacement de types à chaque boundary. as unknown as, sql.json(... as never), des appels sql bruts castés avec as number — le TypeScript était cosmétique.

Quatorze catégories. Six mois de livraison rapide assistée par IA.


Le vrai problème : les instructions dans votre tête n’atteignent pas l’IA

La plupart des équipes gèrent ça avec des docs d’architecture, des notes dans le README ou un onboarding verbal. Ces approches ont une chose en commun : elles existent en dehors de la boucle de codage.

Quand Claude ou Cursor génère un gestionnaire de routes, il ne lit pas votre page Confluence. Il lit le code autour et, si vous en avez un, votre fichier de contexte — CLAUDE.md, .cursorrules, ou un system prompt Codex.

La solution, ce n’est pas plus de documentation. C’est d’encoder vos règles d’architecture directement dans le contexte d’exploitation de l’IA, sous forme de contraintes non négociables avec des patterns imposés. C’est exactement pour ça qu’existe le système de skills de Claude Code.

Un skill est un fichier markdown que Claude Code charge quand il est invoqué. Mais plus concrètement, les règles que vous mettez dans CLAUDE.md sont les instructions que Claude suit pour chaque tâche dans ce projet — automatiquement, sur chaque fichier, à chaque génération.

Quand vous y mettez vos règles d’architecture, l’IA ne les connaît pas seulement. Elle les applique.


Ce qui va dans le skill : 14 règles avec du mordant

Voici la structure qu’on embarque dans chaque engagement maintenant. Ce ne sont pas des suggestions — ce sont des contraintes que l’IA doit satisfaire avant de considérer une tâche comme terminée.

1. Les routes ne font que le transport

// PATTERN REQUIS — les gestionnaires de routes font exactement ça :
export async function POST(req: NextRequest) {
  const input = contactSchema.parse(await req.json());
  const result = await createContact(input);
  return NextResponse.json(result);
}

// VIOLATION — ne jamais faire ça dans une route :
export async function POST(req: NextRequest) {
  const body = await req.json();
  const existing = await db.query("SELECT * FROM contacts WHERE email = $1", [body.email]);
  if (existing.length) return NextResponse.json({ error: "exists" }, { status: 409 });
  await db.query("INSERT INTO contacts ...");
  return NextResponse.json({ ok: true });
}

Règle encodée : Ne jamais mettre de logique métier dans les gestionnaires de routes ou les layouts. Toujours : valider l’input → autoriser → appeler un use-case/service → mapper le résultat en HTTP.

2. Appliquer des couches strictes

// REQUIS — le service dépend d'une interface repository injectée
export async function createLead(input: CreateLeadInput, deps: Deps) {
  const duplicate = await deps.contacts.findByEmail(input.email);
  if (duplicate) throw new DuplicateLeadError();
  return deps.contacts.insert(input);
}

// VIOLATION — le service touche directement le framework et la DB
export async function handleSubmit(input: CreateLeadInput) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const user = await clerkClient.users.getUser(input.userId);
  return db.query("INSERT INTO leads ...");
}

Règle encodée : L’UI appelle les services. Les services appellent les repositories et les adaptateurs. Les repositories parlent à la base de données. L’UI ne doit jamais importer de repositories ou du SQL brut.

3. Séparer les types de persistance, de domaine et les DTOs API

// REQUIS — trois couches distinctes avec mapper explicite
type ContactRow      = { id: string; email: string; created_at: string };
type Contact         = { id: string; email: string; createdAt: Date };
type ContactResponse = { id: string; email: string; createdAt: string };

const toContact = (row: ContactRow): Contact => ({
  id: row.id,
  email: row.email,
  createdAt: new Date(row.created_at),
});

// VIOLATION — retourner une ligne DB brute comme réponse API
const contacts = await sql<Contact[]>`SELECT * FROM contacts`;
return NextResponse.json(contacts);

Règle encodée : Ne jamais retourner des lignes brutes comme types de domaine. Toujours définir Row → Domain → ResponseDTO avec des mappers explicites entre chaque couche.

4. Un système de validation, une enveloppe d’erreur

// REQUIS
const parsed = contactSchema.safeParse(await req.json());
if (!parsed.success) {
  return NextResponse.json(
    { error: "Input invalide", issues: parsed.error.issues },
    { status: 400 }
  );
}

// VIOLATION
const body = await req.json();
if (!body.email || !body.name) {
  return new Response("champs manquants", { status: 400 });
}

Règle encodée : Ne jamais lire des corps de requête bruts et valider les champs à la main. Toujours parser avec Zod. Toujours retourner le même format d’erreur : { error: string, issues?: ... }.

5. Pas de mélange de boundaries internes

// REQUIS — choisir une seule boundary par fonctionnalité
const contacts = await getContacts(); // appel serveur direct

// VIOLATION — mélanger fetch et DB directe pour la même fonctionnalité
const contacts = await fetch("/api/contacts").then(r => r.json());
const more = await db.query("SELECT * FROM contacts");

Règle encodée : Ne jamais appeler votre propre API depuis du code serveur qui utilise aussi un accès DB direct pour la même fonctionnalité. Choisir une boundary et la tenir.

6. Dépendre de ports, pas de singletons SDK

// REQUIS — injecter des interfaces
type Ports = {
  clock: () => Date;
  ai: { generate(prompt: string): Promise<string> };
  payments: { charge(amount: number): Promise<string> };
};

export async function processOrder(ports: Ports, input: OrderInput) {
  const now = ports.clock();
  const chargeId = await ports.payments.charge(input.amount);
  return { chargeId, processedAt: now };
}

// VIOLATION — singleton SDK importé directement dans la logique de domaine
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

Règle encodée : Ne jamais laisser la logique de domaine importer Clerk, Stripe, OpenAI, Redis, le temps ou l’aléatoire directement. Toujours injecter des ports/adaptateurs pour que la logique de base soit testable unitairement.

7. La sécurité doit être explicite et fail-closed

// REQUIS
if (!session.userId) {
  return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
if (!rateLimiterAvailable) {
  return NextResponse.json({ error: "Service indisponible" }, { status: 503 });
}

// VIOLATION — super-utilisateur caché, fail-open silencieux
const userId = process.env.MASTER_USER_ID ?? session.userId;
if (!redis) return handler(); // ignore silencieusement le rate limiting

Règle encodée : Ne jamais encoder l’autorisation, la gestion des tenants ou les contrôles de sécurité dans des raccourcis via variables d’env. Si le rate limiting est indisponible, les routes sensibles doivent rejeter la requête — pas la laisser passer silencieusement.

8. Accès aux données en batch — pas de N+1 dans les chemins de requête

// REQUIS
const users = await db.query(
  "SELECT * FROM users WHERE id = ANY($1)", [ids]
);
const enriched = await Promise.all(
  users.map(u => enrichmentApi.fetch(u.externalId))
);

// VIOLATION
for (const id of ids) {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  enriched.push(await enrichmentApi.fetch(user.externalId));
}

9. Types de lignes explicites pour chaque requête — pas de SELECT *, pas de as unknown as

// REQUIS
type DashboardStatsRow = { active_contacts: number; pending_sends: number };
const [stats] = await sql<DashboardStatsRow[]>`
  SELECT COUNT(*) AS active_contacts, SUM(pending) AS pending_sends
  FROM contacts
`;
return { activeContacts: stats.active_contacts };

// VIOLATION — effacement de type sur un résultat scalaire
const [stats] = await sql`SELECT COUNT(*) ...`;
return { activeContacts: stats.active_contacts as number };

Règles 10–14 : Encodées comme contraintes courtes

Ces règles vont directement dans CLAUDE.md sous forme de règles écrites, sans exemples de code :

  • Les valeurs par défaut produit sont de la config, pas de la logique repository. Ne jamais coder en dur les campagnes d’onboarding ou les poids d’expérience dans des fonctions de requête. Les définir dans des modules seed/config et garder les services runtime pilotés par les données.
  • Pas d’effacement de types aux boundaries DB. Utiliser des types de transaction dérivés, des helpers JSON typés et des vérifications runtime à la place.
  • Les payloads JSONB et les tableaux ont besoin de validation. Ne jamais faire confiance à du JSONB décodé par postgres.js parce qu’il a parsé. Toujours valider la forme et les types des items avec Zod avant usage dans la logique d’enrichissement, les rate limits ou les DTOs de réponse.
  • Garder les modules petits et cohésifs. Un composant qui connaît les URLs des endpoints, fait de la validation, gère de l’état et fait du rendu UI est une violation de politique. Diviser en : UI de présentation, contrôleur de fonctionnalité, service use-case, repository.
  • Les agrégats scalaires ont besoin de formes de lignes typées. as number sur un résultat sql brut est un mensonge. Typer la ligne, aliaser explicitement, lire directement.

Comment câbler ça dans Claude Code

La version minimum viable est un CLAUDE.md à la racine du projet qui inclut ces contraintes sous forme de patterns obligatoires avec des exemples de code bon/mauvais. Claude Code lit ce fichier au début de chaque session et applique les règles à chaque génération.

Les sections qui comptent le plus dans le fichier :

## Règles d'architecture

Ce sont des contraintes non négociables. Les satisfaire toutes avant de considérer une tâche comme terminée.

### Les routes ne font que le transport
Ne jamais mettre de workflows métier dans les gestionnaires de routes, les server components ou les layouts.
Toujours : valider l'input → autoriser → appeler un use-case/service → mapper le résultat en HTTP/UI.

### Appliquer des couches strictes
L'UI peut appeler des services applicatifs typés.
Les services applicatifs peuvent appeler des repositories et des adaptateurs de providers externes.
Les repositories peuvent parler à la base de données.
L'UI ne doit pas importer de repositories ou du SQL brut.

[... règles restantes avec exemples de code ...]

Une version plus robuste utilise le système de skills de Claude Code : un skill dédié nextjs-arch-guard qui s’exécute comme vérification pre-commit sur les fichiers stagés. Si un fichier stagé contient un import DB au niveau d’une route, un retour SELECT *, ou un request.json() brut sans Zod — le skill échoue avant que le code entre dans la codebase.

La différence clé entre documentation et skill : la documentation nécessite qu’un humain s’en souvienne. Un skill nécessite que l’IA le satisfasse avant de considérer quoi que ce soit comme terminé.

Vous pouvez aussi ajouter des hooks d’enforcement dans .claude/settings.json de votre projet pour lancer la vérification arch-guard automatiquement avant chaque commit. L’IA écrit le code ; le skill l’audite ; les mauvais patterns n’atterrissent jamais.


Points clés à retenir

Les outils de codage IA sont indifférents à l’architecture. Ils optimisent pour du code qui fonctionne, pas pour des systèmes maintenables. Les garde-fous doivent venir de vous — encodés dans le contexte, pas écrits dans un README que personne ne lit.

14 anti-patterns dans 73 routes, ce n’est pas de la malchance. C’est ce qui se passe quand une équipe livre vite sans contraintes appliquées par machine. Chaque violation dans ce post-mortem était individuellement raisonnable sur le moment. L’IA a fait le bon choix local à chaque fois. Personne ne surveillait la structure globale.

CLAUDE.md, c’est de l’enforcement architectural, pas juste de la documentation. Quand vos règles vivent dans le contexte d’exploitation de l’IA, elles s’appliquent à chaque génération automatiquement — que ce soit 9h un lundi ou 23h pendant un rush de livraison. L’IA n’oublie pas. Elle ne se fatigue pas. Elle ne saute pas la checklist parce que la deadline, c’est demain.


Chaque engagement qu’on prend inclut un CLAUDE.md spécifique au projet et un skill de garde-fou architectural construit pour votre stack, vos patterns et vos contraintes. On ne suppose pas que l’IA connaît vos standards. On les encode avant qu’elle écrive la première ligne. Contactez-nous si vous voulez la même chose.

Plus d'articles

Tous les articles

TRAVAILLONSENSEMBLE

Vous avez un projet en tête ? Nous serions ravis d'en discuter. Créons quelque chose de grand ensemble !