Skip to content

Architecture hybride Nest + Next.js 15

TL;DR — Nest fait ce que Next ne fera jamais bien : domain layer riche, queues, jobs, WebSocket gateway, OpenAPI strict, gardes/intercepteurs composables, accès DB transactionnel. Next 15 fait ce que Nest ne fera jamais bien : SSR streaming, RSC, edge runtime, hydration intelligente, SEO, déploiement instantané sur Vercel. Le bon découpage : Next porte l'UI et un mince layer de BFF (server actions, route handlers proxy), Nest porte l'API métier et les workers. Le contrat entre les deux passe par des types partagés (zod + ts), un monorepo (pnpm workspaces + Turbo ou Nx), une auth basée cookie HttpOnly ou JWT, et un dev proxy pour aplatir l'origine. Cet article détaille les coupes franches.

🧠 Mental model — ASCII + analogie

L'analogie : Next est la salle de réception d'un restaurant (lumière, déco, hôte d'accueil, menus, addition), Nest est la cuisine (chef, brigades, frigo, fournisseurs, comptabilité). On peut faire un restaurant avec uniquement la salle (un food truck), mais dès qu'il y a 30 plats au menu, des allergènes, des stocks et de la facturation B2B, on sépare. La salle ne touche jamais directement les fournisseurs ; elle passe par la cuisine.

                          ┌────────────────────────────────────────┐
   Browser  ──────────────►│  Next.js 15 (app router, RSC, edge)   │
   (React)                 │                                        │
                           │  - Server Components fetch Nest        │
                           │  - Server Actions (mutations)          │
                           │  - Route Handlers (/api/* proxy)       │
                           │  - Auth: cookie HttpOnly + CSRF token  │
                           └──────────────┬─────────────────────────┘
                                          │ HTTPS internal (mTLS or signed JWT)

                           ┌────────────────────────────────────────┐
                           │  NestJS (Fastify, Node 22)             │
                           │                                        │
                           │  - Domain modules                      │
                           │  - Prisma / Drizzle                    │
                           │  - BullMQ workers                      │
                           │  - WebSocket gateway                   │
                           │  - OpenAPI 3.1 + zod-to-openapi        │
                           └──────────────┬─────────────────────────┘

                              ┌───────────┴────────────┐
                              ▼                        ▼
                         ┌─────────┐              ┌─────────┐
                         │ Postgres│              │  Redis  │
                         └─────────┘              └─────────┘

   Monorepo layout (pnpm + Turbo):
   apps/
     web/      ← Next 15
     api/      ← NestJS
     worker/   ← Nest worker process (réutilise modules)
   packages/
     contracts/   ← zod schemas + tRPC-style types (source of truth)
     db/          ← Prisma client + migrations
     ui/          ← React design system
     config/      ← eslint, tsconfig, prettier shared

Le découpage mental : tout ce qui parle aux utilisateurs (rendering, formulaires, navigation, SEO) vit dans apps/web. Tout ce qui parle aux données (lecture/écriture, calculs, intégrations externes) vit dans apps/api. Les contrats (forme des DTO, validation) vivent dans packages/contracts et sont importés des deux côtés. Les server actions Next ne contiennent jamais de logique métier — elles sont des fonctions de 5 lignes qui valident, appellent Nest, gèrent l'erreur.

🛠️ Code minimal (ts)

Le contrat partagé, source unique de vérité.

ts
// packages/contracts/src/users.ts
import { z } from 'zod';

export const UserIdSchema = z.string().uuid().brand('UserId');
export type UserId = z.infer<typeof UserIdSchema>;

export const UserSchema = z.object({
  id: UserIdSchema,
  email: z.string().email(),
  displayName: z.string().min(1).max(80),
  createdAt: z.string().datetime(),
});
export type User = z.infer<typeof UserSchema>;

export const CreateUserInputSchema = UserSchema.pick({ email: true, displayName: true });
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;

export const userRoutes = {
  list: { method: 'GET', path: '/users' },
  byId: { method: 'GET', path: '/users/:id' },
  create: { method: 'POST', path: '/users' },
} as const;

Côté Nest, on consomme le même schéma zod pour valider entrée ET sortie.

ts
// apps/api/src/users/users.controller.ts
import { Body, Controller, Get, Param, Post, UsePipes } from '@nestjs/common';
import { ZodValidationPipe } from 'nestjs-zod';
import { CreateUserInputSchema, UserSchema, UserIdSchema } from '@workspace/contracts';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get()
  async list(): Promise<unknown> {
    const list = await this.users.list();
    return UserSchema.array().parse(list);
  }

  @Get(':id')
  async byId(@Param('id', new ZodValidationPipe(UserIdSchema)) id: string) {
    const user = await this.users.byId(id);
    return UserSchema.parse(user);
  }

  @Post()
  @UsePipes(new ZodValidationPipe(CreateUserInputSchema))
  async create(@Body() input: unknown) {
    const created = await this.users.create(input as never);
    return UserSchema.parse(created);
  }
}

Côté Next, un client typé minuscule.

ts
// apps/web/src/lib/api.ts
import 'server-only';
import { cookies } from 'next/headers';
import { UserSchema, CreateUserInputSchema, type User, type CreateUserInput } from '@workspace/contracts';

const BASE = process.env.NEST_INTERNAL_URL ?? 'http://localhost:3001';

async function authHeader(): Promise<HeadersInit> {
  const jar = await cookies();
  const session = jar.get('session')?.value;
  return session ? { cookie: `session=${session}` } : {};
}

export const api = {
  async listUsers(): Promise<User[]> {
    const res = await fetch(`${BASE}/users`, {
      headers: await authHeader(),
      next: { revalidate: 30, tags: ['users'] },
    });
    if (!res.ok) throw new Error(`api error ${res.status}`);
    return UserSchema.array().parse(await res.json());
  },

  async createUser(input: CreateUserInput): Promise<User> {
    const parsed = CreateUserInputSchema.parse(input);
    const res = await fetch(`${BASE}/users`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', ...(await authHeader()) },
      body: JSON.stringify(parsed),
    });
    if (!res.ok) throw new Error(`api error ${res.status}`);
    return UserSchema.parse(await res.json());
  },
};

Un Server Component qui consomme.

tsx
// apps/web/src/app/users/page.tsx
import { api } from '@/lib/api';
import { CreateUserForm } from './CreateUserForm';

export default async function UsersPage() {
  const users = await api.listUsers();
  return (
    <main>
      <h1>Utilisateurs</h1>
      <ul>{users.map((u) => <li key={u.id}>{u.displayName} — {u.email}</li>)}</ul>
      <CreateUserForm />
    </main>
  );
}

Un Server Action qui mute.

tsx
// apps/web/src/app/users/CreateUserForm.tsx
'use client';
import { useTransition } from 'react';
import { createUserAction } from './actions';

export function CreateUserForm() {
  const [pending, start] = useTransition();
  return (
    <form action={(fd) => start(() => createUserAction(fd))}>
      <input name="email" type="email" required />
      <input name="displayName" required />
      <button disabled={pending}>{pending ? 'Création...' : 'Créer'}</button>
    </form>
  );
}
ts
// apps/web/src/app/users/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { api } from '@/lib/api';
import { CreateUserInputSchema } from '@workspace/contracts';

export async function createUserAction(formData: FormData) {
  const parsed = CreateUserInputSchema.parse({
    email: formData.get('email'),
    displayName: formData.get('displayName'),
  });
  await api.createUser(parsed);
  revalidateTag('users');
  redirect('/users');
}

🎯 Patterns courants

Auth partagée cookie HttpOnly. Le pattern le plus solide : Nest émet un cookie de session HttpOnly + Secure + SameSite=Lax (ou un JWT signé) lors du login. Next reçoit ce cookie côté navigateur, et le re-transmet à Nest dans les requêtes serveur-à-serveur via cookies() puis fetch({ headers: { cookie } }). Pas de token dans le localStorage (CSP-friendly, XSS-resistant). CSRF géré via SameSite + double-submit cookie pour les server actions non-GET. Si Nest et Next sont sur deux domaines distincts (api.acme.com et app.acme.com), il faut configurer le cookie avec Domain=.acme.com et activer CORS strict.

Voici la séquence d'auth complète, avec login dans Next qui proxie vers Nest.

ts
// apps/web/src/app/auth/login/actions.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { z } from 'zod';

const Credentials = z.object({ email: z.string().email(), password: z.string().min(8) });

export async function loginAction(_prev: unknown, formData: FormData) {
  const parsed = Credentials.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  });
  if (!parsed.success) return { error: 'Identifiants invalides' };

  const res = await fetch(`${process.env.NEST_INTERNAL_URL}/auth/login`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(parsed.data),
  });
  if (!res.ok) return { error: 'Échec de connexion' };

  const setCookie = res.headers.get('set-cookie');
  if (setCookie) {
    const parsedCookie = setCookie.split(';')[0].split('=');
    (await cookies()).set('session', parsedCookie[1], {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 60 * 24 * 7,
    });
  }
  redirect('/dashboard');
}

Le contrôleur Nest reçoit, valide, signe une session courte JWT (stockée Redis si besoin de révocation).

ts
// apps/api/src/auth/auth.controller.ts
import { Body, Controller, Post, Res, UsePipes } from '@nestjs/common';
import { Response } from 'express';
import { ZodValidationPipe } from 'nestjs-zod';
import { z } from 'zod';
import { AuthService } from './auth.service';

const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(8) });

@Controller('auth')
export class AuthController {
  constructor(private readonly auth: AuthService) {}

  @Post('login')
  @UsePipes(new ZodValidationPipe(LoginSchema))
  async login(@Body() body: z.infer<typeof LoginSchema>, @Res({ passthrough: true }) res: Response) {
    const { token, user } = await this.auth.signIn(body.email, body.password);
    res.cookie('session', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000,
      domain: process.env.COOKIE_DOMAIN ?? undefined,
    });
    return { user };
  }
}

JWT signé entre services. Quand Next et Nest communiquent en interne (même VPC, même cluster), au lieu de re-transmettre le cookie utilisateur, Next peut signer un JWT court (30 s) avec une clé partagée et l'attacher dans Authorization: Bearer .... Nest valide la signature et la fraîcheur. Avantage : pas de fuite du cookie utilisateur dans les logs serveur, et possibilité de passer des claims supplémentaires (actAs, correlationId). À combiner avec une auth utilisateur classique au-dessus.

Server Components fetching depuis Nest. Dans app/, les server components peuvent faire await fetch(NEST_URL) directement. Next 15 cache automatiquement les GET selon la directive next: { revalidate, tags }. Pour invalider depuis un Server Action, revalidateTag('users'). Pour invalider depuis Nest lui-même (après une mutation par un worker), exposer un endpoint Next /api/revalidate?tag=users protégé par un secret et appelé par Nest.

Route Handlers Next comme BFF. Plutôt que de faire taper le navigateur directement sur Nest (CORS, double origine), créer des handlers Next app/api/users/route.ts qui proxient. Ils peuvent enrichir (ajouter le user ID depuis la session, filtrer les champs sensibles avant retour), throttler, transformer. C'est un pattern BFF (Backend For Frontend). Inconvénient : un hop supplémentaire, latence +20 à 50 ms. Avantage : Nest reste interne, jamais exposé directement à Internet.

Monorepo pnpm + Turbo. pnpm-workspace.yaml liste apps/* et packages/*. Chaque package a son package.json avec "name": "@workspace/contracts". turbo.json définit le pipeline build, test, lint avec leurs dépendances ("dependsOn": ["^build"]). Le tsconfig racine déclare les paths : "paths": { "@workspace/contracts": ["packages/contracts/src/index.ts"] }. Pas de pré-build des packages locaux en dev : tsx ou Next/Nest les résolvent directement.

Nx alternative. Nx fournit une orchestration plus opinionated (générateurs, plugins Next/Nest/React, dépendances graph visuel, distributed cache). Pour des équipes ≥ 6 dev qui veulent une structure imposée et des outils de scaffolding, Nx est plus efficace. Pour des équipes plus petites qui veulent rester au plus près de l'écosystème natif, pnpm + Turbo est plus léger. Turbo Repo a rattrapé Nx sur le caching distribué (Vercel Remote Cache, gratuit pour Turbo).

Dev proxy. En dev, on fait tourner Next sur :3000 et Nest sur :3001. Pour éviter CORS et garder la même origine, soit Next proxie via un rewrite dans next.config.ts, soit on met Caddy/Traefik devant en local. Le rewrite est plus simple :

ts
// apps/web/next.config.ts
export default {
  async rewrites() {
    return [{ source: '/api-nest/:path*', destination: 'http://localhost:3001/:path*' }];
  },
};

Streaming SSR depuis Nest. RSC + streaming permet de rendre la page en plusieurs vagues. Si Nest expose un endpoint lent (/dashboard/heavy-data), on enveloppe son fetch dans un <Suspense> Next. Pour vraiment streamer (token par token d'un LLM via Nest SSE), on utilise un Client Component qui ouvre un fetch vers /api-nest/chat/stream et lit le ReadableStream. Voir le chapitre SSE.

Déploiement. Next sur Vercel (build edge, ISR, CDN inclus). Nest sur Fly.io / Render / Railway en container Docker, ou sur ECS Fargate / Cloud Run pour des workloads plus exigeants. Le worker BullMQ vit en sidecar du même container ou dans son propre service. La DB Postgres sur Neon / Supabase / RDS. Le Redis sur Upstash (serverless) ou ElastiCache. Configurer les variables d'env Vercel NEST_INTERNAL_URL pointant vers le hostname public de Nest, et un secret HMAC partagé pour signer les JWT inter-services.

Dockerfile multi-stage pour Nest, optimisé pour Fly.io.

dockerfile
# apps/api/Dockerfile
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /repo

FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY packages/contracts/package.json packages/contracts/
COPY packages/db/package.json packages/db/
RUN pnpm install --frozen-lockfile

FROM base AS builder
COPY --from=deps /repo/node_modules ./node_modules
COPY . .
RUN pnpm --filter @workspace/api... build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /repo/apps/api/dist ./dist
COPY --from=builder /repo/node_modules ./node_modules
COPY --from=builder /repo/apps/api/package.json ./
EXPOSE 3001
CMD ["node", "dist/main.js"]

fly.toml minimal.

toml
app = "acme-api"
primary_region = "cdg"

[build]
  dockerfile = "apps/api/Dockerfile"

[http_service]
  internal_port = 3001
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 1

[[http_service.checks]]
  interval = "10s"
  timeout = "2s"
  grace_period = "10s"
  method = "GET"
  path = "/health"

[env]
  NEXT_REVALIDATE_URL = "https://app.acme.com/api/revalidate"

Revalidation cross-service. Quand un worker BullMQ Nest met à jour la DB (par exemple un import batch), Next ne sait pas qu'il faut invalider son cache. Solution : exposer un endpoint Next /api/revalidate protégé par secret, appelé par Nest après mutation.

ts
// apps/web/src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const auth = req.headers.get('x-revalidate-secret');
  if (auth !== process.env.REVALIDATE_SECRET) return new NextResponse('forbidden', { status: 403 });
  const { tags } = (await req.json()) as { tags: string[] };
  for (const tag of tags ?? []) revalidateTag(tag);
  return NextResponse.json({ revalidated: tags.length });
}
ts
// apps/api/src/cache/cache-invalidator.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class CacheInvalidator {
  async invalidate(tags: string[]): Promise<void> {
    await fetch(`${process.env.NEXT_REVALIDATE_URL}`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'x-revalidate-secret': process.env.REVALIDATE_SECRET!,
      },
      body: JSON.stringify({ tags }),
    });
  }
}

🔄 Versions — Nest 7 → 11 + libs

AnnéeNextNestParticularité
202010 (pages router)7Hybride très rare, on faisait CRA + Nest.
202111–128next dev plus rapide, getServerSideProps calls Nest via fetch.
202213 (app router beta)9RSC arrive, mais beaucoup d'équipes restent sur pages router.
202314 (app router stable)10Adoption massive de Server Actions, pnpm workspaces, Turbo.
202415 (React 19, Turbopack)11App router devient le défaut, RSC mature, edge runtime stable.
202615+ / 1611 / 12Partial Prerendering, after() API pour tasks post-response.

Libs clés en 2026 : zod ≥ 3.23, nestjs-zod ≥ 4.x, @nestjs/swagger ≥ 8.x, @nestjs/platform-fastify ≥ 11, next-auth (Auth.js) v5 ou Lucia. Pour le monorepo : pnpm ≥ 9, turbo ≥ 2, typescript ≥ 5.5 (pour using et decorators stables).

⚠️ Pitfalls — 10 à connaître

  1. Logique métier dans Server Actions. Tentant : écrire la validation, la persistance, l'envoi d'email directement dans le 'use server'. Désastre à long terme : zéro réutilisabilité (le worker BullMQ ne peut pas appeler une Server Action), zéro testabilité unitaire propre, couplage Next ↔ DB qui empêche de remplacer Next un jour. Règle d'or : Server Action = parsing zod + appel API Nest + revalidation cache, point.

  2. Types divergents entre Next et Nest. Sans package partagé, chaque côté redéfinit User à sa sauce. Pendant 6 mois ça va, puis on rename un champ d'un côté, on oublie l'autre, et le client reçoit undefined. Mettre les types dans packages/contracts est non négociable.

  3. CORS mal configuré. Si Nest est exposé directement au navigateur, configurer enableCors({ origin: ['https://app.acme.com'], credentials: true }). Mais credentials=true interdit origin: '*'. Si Next BFF proxie, on n'a aucun CORS à configurer côté Nest.

  4. Double validation oubliée. Côté Next on valide avec zod avant d'appeler Nest. Tentation : ne pas re-valider côté Nest « puisque Next a déjà validé ». Mauvaise idée : un attaquant peut taper Nest directement. Validation côté Nest est obligatoire ; côté Next c'est pour l'UX (afficher l'erreur sans round-trip).

  5. Cache Next agressif sur données privées. fetch Next cache par défaut. Si un endpoint Nest retourne des données spécifiques à l'utilisateur (/me), il faut cache: 'no-store' ou next: { tags: ['user-${userId}'] }. Sinon, l'utilisateur A peut voir les données de l'utilisateur B.

  6. cookies() async oublié. En Next 15, cookies() et headers() sont devenus async (Dynamic APIs). const jar = cookies() sans await ne lève pas d'erreur compilation immédiate mais casse en runtime. Toujours const jar = await cookies().

  7. Hot reload casse en monorepo. Quand on modifie packages/contracts, Next ne recompile pas toujours. Solution : transpilePackages: ['@workspace/contracts'] dans next.config.ts. Côté Nest, nest start --watch suit les changements si le path TS est résolu correctement.

  8. Déploiement Next bloque sur lib native Nest. Si Nest utilise sharp, bcrypt, argon2, Vercel ne les compile pas. Si on tente de bundle Nest dans Next (mauvaise idée), ça pète. Garder Nest sur son propre runtime.

  9. JWT trop long expose au replay. Si on signe des JWT inter-services à durée 1 h pour éviter de re-signer à chaque appel, un attaquant qui intercepte un JWT a 59 minutes pour rejouer. Préférer 30 s d'expiration + signature à chaque requête.

  10. Bundling de @workspace/contracts côté client. Si on importe packages/contracts dans un Client Component sans précaution, tout le code zod part dans le bundle JS du navigateur (+15 KB gzip). Garder les schémas en packages/contracts/src/index.ts pur (pas de dépendance sur Nest, Prisma, bcrypt) pour qu'ils restent isomorphes.

  11. use server qui leak des secrets. Une server action peut accidentellement renvoyer un objet contenant un token API serveur en valeur de retour. Next sérialise ça vers le client → fuite. Toujours retourner des DTO épurés, jamais des entités DB ou des résultats fetch bruts.

  12. fetch dans un Server Component sans next: { revalidate }. Par défaut Next cache infiniment. Si le endpoint Nest change, le navigateur voit l'ancienne version pendant des jours. Toujours expliciter : cache: 'no-store' pour données fraîches, next: { revalidate: 30 } pour ISR, next: { tags: [...] } pour invalidation à la demande.

  13. Mismatch de fuseaux horaires. Nest tourne sur Cloud Run en UTC, Next sur Vercel en UTC, mais le développeur affiche en Europe/Paris. Si on sérialise new Date() en toString() (au lieu d'toISOString()), on émet une chaîne dépendante du fuseau. Toujours échanger en ISO 8601 UTC, formatter à l'affichage uniquement.

  14. Edge runtime Next avec lib Node-only. Si une route handler Next a export const runtime = 'edge' et importe une lib qui utilise node:crypto ou Buffer, le build échoue ou crashe en prod. Edge runtime ≠ Node — c'est V8 isolate. Utiliser Web Crypto API et Uint8Array.

  15. OpenAPI Nest pas exposé à Next. Si Nest génère un swagger via @nestjs/swagger, on peut le consommer côté Next pour générer un client TypeScript automatiquement via openapi-typescript ou orval. Ne pas faire ça = chaque dev recopie les types à la main. Pipeline CI : pnpm --filter api openapi:export produit openapi.json, pnpm --filter web codegen génère le client.

🧪 Testing

Tester l'hybride exige trois niveaux : tests unitaires Nest (service, controller via Test.createTestingModule), tests contract qui vérifient que zod côté Next parse correctement les payloads renvoyés par Nest, tests E2E Next via Playwright qui taperont la vraie API Nest (en dev local ou contre un environnement éphémère).

ts
// apps/api/test/users.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
import * as request from 'supertest';

describe('Users API', () => {
  let app: import('@nestjs/common').INestApplication;

  beforeAll(async () => {
    const mod = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = mod.createNestApplication();
    await app.init();
  });
  afterAll(() => app.close());

  it('rejects invalid create payload', async () => {
    const res = await request(app.getHttpServer())
      .post('/users')
      .send({ email: 'not-an-email', displayName: '' });
    expect(res.status).toBe(400);
  });

  it('creates and retrieves a user', async () => {
    const created = await request(app.getHttpServer())
      .post('/users')
      .send({ email: '[email protected]', displayName: 'Alice' });
    expect(created.status).toBe(201);
    const fetched = await request(app.getHttpServer()).get(`/users/${created.body.id}`);
    expect(fetched.body.email).toBe('[email protected]');
  });
});
ts
// apps/web/tests/users.spec.ts (Playwright)
import { test, expect } from '@playwright/test';

test('user creation flow', async ({ page }) => {
  await page.goto('/users');
  await page.fill('input[name="email"]', '[email protected]');
  await page.fill('input[name="displayName"]', 'Bob');
  await page.click('button[type="submit"]');
  await expect(page.getByText('Bob — [email protected]')).toBeVisible();
});

Pour tester la contractualité, un test côté packages/contracts qui parse un payload réel renvoyé par Nest (capturé en CI via un snapshot) et vérifie zéro warning zod.

ts
// packages/contracts/test/users.contract.spec.ts
import { UserSchema } from '../src/users';

const fixtures = {
  ok: { id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', email: '[email protected]', displayName: 'A', createdAt: '2026-05-24T00:00:00Z' },
  badDate: { id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', email: '[email protected]', displayName: 'A', createdAt: 'not-a-date' },
};

describe('UserSchema', () => {
  it('parses a valid payload', () => {
    expect(() => UserSchema.parse(fixtures.ok)).not.toThrow();
  });
  it('rejects invalid createdAt', () => {
    expect(() => UserSchema.parse(fixtures.badDate)).toThrow();
  });
  it('strips unknown fields silently', () => {
    const parsed = UserSchema.parse({ ...fixtures.ok, extra: 'x' });
    expect((parsed as any).extra).toBeUndefined();
  });
});

Un test de bout-en-bout avec MSW (Mock Service Worker) pour valider le BFF Next sans démarrer Nest.

ts
// apps/web/test/api.client.spec.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { api } from '../src/lib/api';

const server = setupServer(
  http.get('http://localhost:3001/users', () =>
    HttpResponse.json([
      { id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', email: '[email protected]', displayName: 'A', createdAt: '2026-05-24T00:00:00Z' },
    ]),
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('listUsers returns typed payload', async () => {
  process.env.NEST_INTERNAL_URL = 'http://localhost:3001';
  const users = await api.listUsers();
  expect(users[0].displayName).toBe('A');
});

🎬 Cas d'usage concrets

SaaS RH — Next portail RH + Nest API métier

Qui : éditeur SaaS RH multi-tenant, 250 entreprises clientes. Portail collaborateur en Next.js (consultation paie, demandes de congés), back-office RH dense (workflows, paie, recrutement) sur Nest.

Problème : besoin d'un SEO correct sur la landing public (/, /pricing, /features), mais aussi d'un portail authentifié performant. La logique métier est lourde (calcul de paie, workflow d'approbation, intégrations URSSAF) et ne doit pas vivre dans des Next API routes.

ts
// apps/api/src/payroll/payroll.controller.ts (Nest)
@Controller('payroll')
@UseGuards(JwtAuthGuard, TenantGuard)
export class PayrollController {
  @Get(':employeeId/payslips/:month')
  async getPayslip(@Param('employeeId') id: string, @Param('month') month: string, @CurrentUser() user: User) {
    return this.payroll.computePayslip(id, month, user.tenantId);
  }
}

// apps/web/app/payslips/[month]/page.tsx (Next.js)
export default async function PayslipPage({ params }: { params: Promise<{ month: string }> }) {
  const { month } = await params; // Next 15: params est une Promise
  const session = await getServerSession();
  const res = await fetch(`${process.env.NEXT_PRIVATE_API_URL}/payroll/${session.user.id}/payslips/${month}`, {
    headers: { Authorization: `Bearer ${session.serverToken}` },
    cache: 'no-store',
  });
  const payslip = await res.json();
  return <PayslipViewer payslip={payslip} />;
}

Gains : SEO landing au top (CWV vert), portail rapide (RSC + cache fetch), code métier paie dense côté Nest avec testing isolé. Migration possible vers une app mobile en réutilisant l'API Nest sans toucher au Next.

E-commerce — Next storefront + Nest API marketplace

Qui : marketplace de matériel professionnel B2B, 8 000 SKU, 200 vendeurs. Storefront en Next.js avec ISR pour les fiches produits, back-office vendeur et acheteur en Next, API métier marketplace en Nest (commission, payout, modération, recherche Elastic).

Problème : les fiches produits doivent être SEO-friendly et rapidement crawlables. Le back-office vendeur exige des features riches (BullMQ pour les imports CSV, websockets pour les notifications de commande). Impossible de mettre tout ça dans Next API routes.

ts
// apps/web/app/products/[slug]/page.tsx (Next.js ISR)
export const revalidate = 300; // 5 min ISR

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params; // Next 15: params est asynchrone
  const product = await fetch(`${process.env.API_URL}/public/products/${slug}`).then((r) => r.json());
  return (
    <>
      <ProductSchemaOrg product={product} />
      <ProductDetail product={product} />
    </>
  );
}

// apps/api/src/orders/order.processor.ts (Nest worker)
@Processor('csv-imports')
export class CsvImportProcessor extends WorkerHost {
  async process(job: Job<{ vendorId: string; s3Key: string }>) {
    const stream = await this.s3.getObjectStream(job.data.s3Key);
    const rows = await parseStream(stream);
    return this.catalog.bulkUpsert(job.data.vendorId, rows);
  }
}

Gains : 4M pages produit indexées Google, conversion +14% grâce au LCP < 1,5 s. Imports CSV vendeurs résilients (retry, observabilité BullMQ). Les vendeurs ont accès à un back-office complet sans dégrader le store front.

Banque privée — fronts client/conseiller + back Nest

Qui : banque privée digitale, 30 000 clients fortunés. Trois fronts distincts (client.banque.com, conseiller.banque.com, admin.banque.com) tous en Next.js, une seule API Nest qui gère KYC, ordres de bourse, reporting, conformité.

Problème : trois interfaces avec des UX très différentes mais qui consomment la même API. Conformité bancaire qui impose un audit log strict côté API. Logique métier riche (ACL par fortune, par produit, par segmentation).

ts
// apps/api/src/orders/order.controller.ts
@Controller('orders')
@UseGuards(JwtAuthGuard, AbacGuard)
export class OrderController {
  @Post()
  @CheckAbility('create', Order)
  async placeOrder(@Body() dto: PlaceOrderDto, @CurrentUser() user: User) {
    return this.orderService.place(dto, user);
  }
}

// apps/conseiller-web/app/clients/[id]/orders/new/page.tsx
// apps/client-web/app/portfolio/orders/new/page.tsx
// 2 UI distinctes, 1 endpoint Nest, ACL différents par profil

Gains : un seul point de vérité métier, trois UX adaptées sans duplication de logique. L'audit conformité passe sur l'API (une seule source à inspecter). Les équipes front (3 squads) avancent indépendamment sans toucher au back.

🛠️ Exemple end-to-end

Contexte : SaaS de gestion de campagnes marketing B2B. Front Next.js (App Router + RSC) pour le dashboard et la landing publique, API Nest pour la logique métier (création campagne, segmentation contacts, scheduling envoi, intégration SendGrid, webhooks). Authentification partagée via JWT propagé en cookie httpOnly. Le serveur Next appelle l'API Nest depuis le réseau interne, le browser ne parle qu'au Next.

ts
// apps/api/src/campaigns/campaigns.controller.ts
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';

@ApiTags('campaigns')
@Controller('campaigns')
@UseGuards(JwtAuthGuard, TenantGuard)
export class CampaignsController {
  constructor(
    private readonly campaigns: CampaignsService,
    private readonly scheduler: CampaignSchedulerService,
  ) {}

  @Get()
  async list(@CurrentUser() user: User) {
    return this.campaigns.findAllForTenant(user.tenantId);
  }

  @Get(':id')
  async show(@Param('id') id: string, @CurrentUser() user: User) {
    return this.campaigns.findOne(id, user.tenantId);
  }

  @Post()
  async create(@Body() dto: CreateCampaignDto, @CurrentUser() user: User) {
    const campaign = await this.campaigns.create(dto, user);
    if (dto.scheduleAt) {
      await this.scheduler.schedule(campaign.id, dto.scheduleAt);
    }
    return campaign;
  }

  @Post(':id/launch')
  async launch(@Param('id') id: string, @CurrentUser() user: User) {
    return this.scheduler.launchNow(id, user);
  }
}

// apps/api/src/campaigns/campaigns.processor.ts
@Processor('campaign-send')
export class CampaignSendProcessor extends WorkerHost {
  constructor(
    private readonly campaigns: CampaignsRepository,
    private readonly contacts: ContactsRepository,
    private readonly sendgrid: SendGridService,
    private readonly events: EventEmitter2,
  ) { super(); }

  async process(job: Job<{ campaignId: string }>) {
    const campaign = await this.campaigns.findOne(job.data.campaignId);
    const contacts = await this.contacts.streamBySegment(campaign.segmentId);
    let sent = 0;
    for await (const batch of chunk(contacts, 100)) {
      await this.sendgrid.sendBatch(batch.map((c) => ({
        to: c.email,
        from: campaign.from,
        subject: campaign.subject,
        html: this.renderTemplate(campaign.template, c),
      })));
      sent += batch.length;
      await job.updateProgress({ sent });
    }
    this.events.emit('campaign.sent', { campaignId: campaign.id, count: sent });
    return { sent };
  }

  private renderTemplate(template: string, contact: Contact): string {
    return template.replace(/{{firstName}}/g, contact.firstName);
  }
}

// apps/web/lib/api-server.ts (Next.js — server-only)
import 'server-only';
import { cookies } from 'next/headers';

export async function apiServer<T>(path: string, init?: RequestInit): Promise<T> {
  const token = (await cookies()).get('session')?.value; // Next 15: cookies() est async
  if (!token) throw new Error('Not authenticated');
  const res = await fetch(`${process.env.API_INTERNAL_URL}${path}`, {
    ...init,
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${token}`,
      ...(init?.headers ?? {}),
    },
    cache: 'no-store',
  });
  if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
  return res.json() as Promise<T>;
}

// apps/web/app/(dashboard)/campaigns/page.tsx (Next.js RSC)
import { apiServer } from '@/lib/api-server';
import { CampaignList } from './CampaignList';
import { CreateCampaignButton } from './CreateCampaignButton';

interface Campaign {
  id: string;
  name: string;
  status: 'DRAFT' | 'SCHEDULED' | 'SENT';
  recipientCount: number;
  scheduledAt?: string;
}

export const dynamic = 'force-dynamic';

export default async function CampaignsPage() {
  const campaigns = await apiServer<Campaign[]>('/campaigns');
  return (
    <main>
      <header className="flex justify-between">
        <h1>Campagnes</h1>
        <CreateCampaignButton />
      </header>
      <CampaignList campaigns={campaigns} />
    </main>
  );
}

// apps/web/app/(dashboard)/campaigns/CampaignList.tsx (Client component)
'use client';
import { useTransition } from 'react';
import { launchCampaignAction } from './actions';

export function CampaignList({ campaigns }: { campaigns: Campaign[] }) {
  const [pending, start] = useTransition();
  return (
    <ul>
      {campaigns.map((c) => (
        <li key={c.id}>
          <span>{c.name}</span>
          {c.status === 'DRAFT' && (
            <button
              disabled={pending}
              onClick={() => start(() => launchCampaignAction(c.id))}
            >Lancer</button>
          )}
        </li>
      ))}
    </ul>
  );
}

// apps/web/app/(dashboard)/campaigns/actions.ts (Next Server Action)
'use server';
import { apiServer } from '@/lib/api-server';
import { revalidatePath } from 'next/cache';

export async function launchCampaignAction(campaignId: string) {
  await apiServer(`/campaigns/${campaignId}/launch`, { method: 'POST' });
  revalidatePath('/campaigns');
}

Frontière nette : Next sert l'UI (RSC + Server Actions), Nest sert l'API métier (controllers + workers BullMQ + intégrations). Le Next ne parle qu'au Nest via apiServer (token signé en cookie, jamais exposé browser). Les Server Actions Next deviennent des thin wrappers vers Nest, jamais de logique métier dedans. Workers BullMQ tournent dans un process séparé du serveur HTTP Nest pour l'isolation. Cette architecture a permis à l'équipe de scaler de 3 à 12 développeurs en gardant des frontières claires entre les responsabilités.


🧭 Frontière BFF — la décision qui structure tout

La question qui revient en revue d'archi : où vit chaque responsabilité ? Voici la grille qu'un staff engineer applique sans hésiter. Le critère n'est pas « est-ce possible dans Next ? » (presque tout l'est) mais « est-ce que cette responsabilité doit être réutilisable, testable hors-HTTP, et survivre à un remplacement de Next ? ».

ResponsabilitéNext (web/BFF)Nest (api/worker)Pourquoi
Rendering / SEO / hydrationNext est conçu pour ça, Nest n'a pas de moteur RSC.
Validation d'input UX (afficher l'erreur sans round-trip)Miroir zod pour le feedback immédiat.
Validation d'autorité (source de vérité)Un attaquant tape Nest directement. Toujours re-valider.
Logique métier, calculs, règlesRéutilisée par worker, CLI, mobile. Testable sans HTTP.
Accès DB transactionnelUne seule frontière de transaction, un seul pool de connexions.
Queues / jobs / cronNext serverless ne tient pas un worker long-running.
WebSocket / SSE long-lived⚠️ proxyEdge/serverless coupe les connexions ; Nest tient le socket.
Cache HTTP / ISR / revalidation⚠️ déclencheNext possède le cache, Nest le notifie via /api/revalidate.
Agrégation multi-endpoints (BFF)Réduire le chatter réseau navigateur↔serveur.
Secrets / clés API tierces (Stripe, LLM)⚠️ jamais au clientLe secret vit côté serveur ; idéalement dans Nest.

Mental model de la frontière : trace une ligne. À gauche (Next), tout ce qui change si le designer change d'avis. À droite (Nest), tout ce qui change si le métier change de règle. Une Server Action qui contient un if (montant > plafond) est du métier qui a fui à gauche : c'est un bug d'architecture, pas de code.

📡 Observabilité du hop Next → Nest (production)

Le défaut numéro un en prod d'une stack hybride : une requête qui traverse 3 process (browser → Next RSC → Nest → DB) et personne ne sait où elle a passé 800 ms. La discipline non négociable : propager un traceparent W3C de bout en bout pour que Next et Nest apparaissent dans la même trace distribuée.

ts
// apps/web/src/lib/api.ts — propagation du contexte de trace
import { trace, context, propagation } from '@opentelemetry/api';

async function tracedFetch(url: string, init: RequestInit = {}): Promise<Response> {
  const headers = new Headers(init.headers);
  // Injecte traceparent + tracestate dans les headers sortants
  propagation.inject(context.active(), headers, {
    set: (carrier, key, value) => (carrier as Headers).set(key, value),
  });
  // Corrélation applicative en plus de la trace OTel
  headers.set('x-request-id', headers.get('x-request-id') ?? crypto.randomUUID());
  return fetch(url, { ...init, headers });
}

Côté Nest, un middleware qui ré-extrait le contexte et le log structuré l'inclut. Avec @opentelemetry/auto-instrumentations-node, la jonction est automatique si les headers passent. La règle : ne jamais fetch Nest sans propager traceparent et x-request-id — sinon chaque service produit des traces orphelines.

Les quatre signaux à instrumenter sur le hop :

SignalMétriqueSeuil d'alerte typiqueCe qu'il révèle
Latence Next→Nesthistogramme p50/p95/p99 par routep99 > 500 mshop interne lent, N+1 fetch RSC
Taux d'erreur du hopratio 5xx/total> 1 % sur 5 minNest down, timeout, déploiement cassé
Cache hit ratio Nexthits / (hits+miss)< 60 % inattenducache: 'no-store' posé partout par erreur
Connexions DB Nestpool en usage / max> 80 %fetch RSC non cachés qui martèlent Nest

Failure modes à connaître par cœur :

  • Cascade de timeouts. Next a un timeout fetch par défaut généreux ; si Nest rame, une page RSC bloque toute la réponse streamée. Mets un AbortSignal.timeout(3000) sur chaque fetch interne et un fallback <Suspense>/error.tsx.
  • Retry storm. Si Next retry automatiquement les fetch 5xx et que Nest est déjà saturé, tu amplifies la panne. Pas de retry naïf sur le hop ; un circuit breaker (ex. opossum) qui ouvre après N échecs.
  • Thundering herd au revalidate. revalidateTag('products') invalide tout en même temps ; 10k requêtes frappent Nest à la milliseconde suivante. Mitige avec stale-while-revalidate et un cache court côté Nest.
  • Connection pool exhaustion. Chaque Server Component non caché ouvre potentiellement une transaction DB via Nest. Sous trafic, le pool Prisma explose. Solution : cacher agressivement les lectures publiques (ISR/tags), réserver no-store aux données strictement personnelles.

🤖 Servir un agent IA depuis Nest, le streamer dans Next

C'est le cas d'usage qui justifie le plus la frontière hybride aujourd'hui : Nest orchestre l'agent (boucle tool-use, secret de l'API, budget, idempotence), Next streame les tokens dans une UI réactive. Le navigateur ne voit jamais la clé Anthropic, ne pilote jamais la boucle d'outils — il consomme un flux SSE.

Modèles Anthropic de référence (2026) : claude-opus-4-8 (flagship, raisonnement/agents), claude-sonnet-4-6 (équilibre coût/qualité, défaut produit), claude-haiku-4-5 (latence/coût bas, classification, garde-fous). Toujours en streaming + retries du SDK. Jamais new Anthropic() dans un champ de service : on l'injecte via DI (forRootAsync) pour la testabilité, la config par environnement, et le mock en test.

Le client LLM injecté (jamais instancié à la main) :

ts
// apps/api/src/llm/llm.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';

export const ANTHROPIC = Symbol('ANTHROPIC');

@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: ANTHROPIC,
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) =>
        new Anthropic({
          apiKey: cfg.getOrThrow<string>('ANTHROPIC_API_KEY'),
          maxRetries: 3, // backoff exponentiel géré par le SDK
          timeout: 60_000,
        }),
    },
  ],
  exports: [ANTHROPIC],
})
export class LlmModule {}

L'endpoint SSE Nest qui streame, avec annulation sur déconnexion client :

ts
// apps/api/src/chat/chat.controller.ts
import { Controller, Inject, Post, Body, Res, Req } from '@nestjs/common';
import type { Response, Request } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';

@Controller('chat')
export class ChatController {
  constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}

  @Post('stream')
  async stream(@Body() body: { messages: Anthropic.MessageParam[] }, @Req() req: Request, @Res() res: Response) {
    res.set({
      'content-type': 'text/event-stream',
      'cache-control': 'no-cache, no-transform',
      connection: 'keep-alive',
    });
    res.flushHeaders();

    // Annulation serveur quand le client (Next) coupe la connexion
    const ac = new AbortController();
    req.on('close', () => ac.abort());

    try {
      const stream = this.anthropic.messages.stream(
        { model: 'claude-sonnet-4-6', max_tokens: 1024, messages: body.messages },
        { signal: ac.signal },
      );
      for await (const event of stream) {
        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
          res.write(`event: token\ndata: ${JSON.stringify({ text: event.delta.text })}\n\n`);
        }
      }
      res.write(`event: done\ndata: {}\n\n`);
    } catch (err) {
      if (!ac.signal.aborted) {
        res.write(`event: error\ndata: ${JSON.stringify({ message: 'stream_failed' })}\n\n`);
      }
    } finally {
      res.end();
    }
  }
}

Le proxy SSE côté Next (Route Handler) — il relaie le flux sans casser le streaming :

ts
// apps/web/src/app/api/chat/route.ts
import { cookies } from 'next/headers';

export async function POST(req: Request) {
  const session = (await cookies()).get('session')?.value;
  // On relaie le ReadableStream de Nest tel quel : pas de buffering, le SSE reste vivant
  const upstream = await fetch(`${process.env.NEST_INTERNAL_URL}/chat/stream`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...(session ? { cookie: `session=${session}` } : {}) },
    body: await req.text(),
    signal: req.signal, // propage l'annulation client → Next → Nest
  });
  return new Response(upstream.body, {
    headers: {
      'content-type': 'text/event-stream',
      'cache-control': 'no-cache, no-transform',
      connection: 'keep-alive',
    },
  });
}

Note edge. Ce Route Handler doit tourner en runtime Node (export const runtime = 'nodejs'), pas edge, si tu veux du keep-alive long et un contrôle fin du body. L'edge runtime supporte le streaming mais avec des limites de durée selon la plateforme.

Le composant client Next qui rend les tokens (signal → état React, AbortController câblé au bouton Stop) :

tsx
// apps/web/src/app/chat/ChatBox.tsx
'use client';
import { useState, useRef } from 'react';

export function ChatBox() {
  const [text, setText] = useState('');
  const [streaming, setStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  async function send(prompt: string) {
    setText('');
    setStreaming(true);
    const ac = new AbortController();
    abortRef.current = ac;
    const res = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ messages: [{ role: 'user', content: prompt }] }),
      signal: ac.signal,
    });
    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    try {
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        // Parse SSE: chaque event séparé par double newline
        for (const frame of buffer.split('\n\n')) {
          const m = frame.match(/^event: token\ndata: (.+)$/m);
          if (m) setText((prev) => prev + JSON.parse(m[1]).text);
        }
        buffer = buffer.slice(buffer.lastIndexOf('\n\n') + 2);
      }
    } catch (e) {
      if (!ac.signal.aborted) setText((p) => p + '\n[erreur de flux]');
    } finally {
      setStreaming(false);
    }
  }

  return (
    <div>
      <pre>{text}</pre>
      {streaming && <button onClick={() => abortRef.current?.abort()}>Stop</button>}
    </div>
  );
}

Le Stop annule l'AbortController client → req.signal du Route Handler → signal du fetch upstream → req.on('close') côté Nest → ac.abort() sur le stream Anthropic. L'annulation traverse les trois process : c'est ce qui évite de payer des tokens pour une réponse que personne ne lit.

Boucle agentique tool-use, côté Nest. L'agent décide d'appeler un outil ; Nest l'exécute (jamais le navigateur), réinjecte le résultat, et boucle jusqu'à stop_reason !== 'tool_use'. Le secret, le budget et l'accès DB restent côté serveur.

ts
// apps/api/src/chat/agent.service.ts (extrait — boucle simplifiée)
async runAgent(messages: Anthropic.MessageParam[], maxSteps = 8) {
  for (let step = 0; step < maxSteps; step++) {
    const res = await this.anthropic.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      tools: this.toolDefs,
      messages,
    });
    messages.push({ role: 'assistant', content: res.content });
    if (res.stop_reason !== 'tool_use') return res; // réponse finale
    const toolResults = await Promise.all(
      res.content
        .filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
        .map(async (b) => ({
          type: 'tool_result' as const,
          tool_use_id: b.id,
          content: JSON.stringify(await this.executeTool(b.name, b.input)),
        })),
    );
    messages.push({ role: 'user', content: toolResults });
  }
  throw new Error('max agent steps exceeded'); // garde-fou anti-boucle infinie
}

Jobs IA durables via BullMQ (génération longue, batch). Pour une génération qui dépasse la durée d'une requête HTTP (rapport de 50 pages, embeddings de 100k docs), on passe par une queue. Trois invariants d'un staff engineer :

  • Idempotence : jobId = generationId (un id métier stable). Un retry ou un double-clic ne relance pas la facturation tokens. queue.add('gen', data, { jobId: generationId }).
  • Retry cost-aware : attempts: 3, backoff: { type: 'exponential', delay: 2000 }, mais pas de retry sur une erreur 400/invalid_request (le prompt est mauvais, retry = brûler de l'argent). Retry seulement sur 429/529/5xx.
  • Partial-output : streamer les tokens dans Redis/DB au fil de l'eau (job.updateProgress) pour qu'un crash worker ne perde pas 30 s de génération ; reprendre depuis le dernier checkpoint.

Garde-fous au bord (avant même d'appeler le LLM) : rate-limit par utilisateur (@nestjs/throttler), cost-guard (refuser si le quota mensuel tokens du tenant est dépassé), et idempotency-key sur le POST initial pour qu'un retry réseau du navigateur ne déclenche pas deux générations facturées. Ces trois gardes vivent côté Nest, jamais dans la Server Action Next.

🔁 Quand utiliser / éviter

À utiliser quand : on a un domaine métier non trivial (≥ 10 entités, des workers, des intégrations payment/email/storage), une équipe ≥ 3 dev avec spé front et back distinctes, des besoins de réutilisation de logique (mobile app future, CLI admin, worker batch), des contraintes de SEO ET de domain logic riche. C'est la stack par défaut pour SaaS B2B sérieux.

À éviter quand : MVP solo dev sur 3 mois — Next API routes ou Next + Convex/Supabase suffit, on ne paye pas la complexité monorepo. Site marketing pur — Next seul, voire Astro. App mobile-only — Nest seul + React Native. App temps réel haute fréquence — Nest + WebSocket + un front custom (Vite, pas Next, car app router ne shine pas sur des dashboards full client). API pure (pas d'UI) — Nest seul, pas besoin de Next.

Le piège classique : tout faire en Next API routes parce que « c'est plus simple ». Au-dessus de 50 endpoints, on regrette amèrement : pas de DI, pas de gardes composables, pas de OpenAPI auto, debug pénible, queues impossibles à modéliser proprement.

🔗 Liens

  • Next 15 release notes : https://nextjs.org/blog/next-15
  • Nest docs : https://docs.nestjs.com/
  • pnpm workspaces : https://pnpm.io/workspaces
  • Turbo : https://turbo.build/repo/docs
  • Nx Next/Nest plugins : https://nx.dev/nx-api/next et https://nx.dev/nx-api/nest
  • nestjs-zod : https://github.com/risenforces/nestjs-zod
  • React Server Components RFC : https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
  • Auth.js v5 : https://authjs.dev/
  • Vercel Remote Cache (Turbo) : https://vercel.com/docs/monorepos/remote-caching
  • Fly.io NestJS guide : https://fly.io/docs/languages-and-frameworks/node/
  • OpenTelemetry W3C Trace Context : https://www.w3.org/TR/trace-context/
  • Anthropic SDK (streaming, tool use) : https://docs.anthropic.com/

🏋️ Exercices

Progression : on construit la frontière, on la durcit pour la prod, puis on la casse pour comprendre ses failles.

1. Contrat partagé end-to-end (implémenter)

Objectif : faire transiter une entité Invoice (zod) de packages/contracts jusqu'à un Server Component, sans aucun type dupliqué.

Crée InvoiceSchema (id brandé, montant en centimes z.number().int().positive(), status, issuedAt ISO). Expose GET /invoices côté Nest qui parse la sortie, consomme-le dans apps/web avec un client typé, et rends-le en RSC. Le test de réussite : renomme un champ dans le schéma et vérifie que tsc casse des deux côtés au même build.

Indice : paths dans le tsconfig racine + transpilePackages: ['@workspace/contracts'] dans next.config.ts, sinon le hot reload du package ne propage pas. Le schéma doit rester isomorphe (zéro import Prisma/Nest) pour ne pas polluer le bundle client.

Objectif : login → cookie HttpOnly émis par Nest → mutation worker → invalidation du cache Next, sans que le navigateur ne touche jamais Nest.

Implémente le flow login complet (Server Action Next → /auth/login Nest → Set-Cookie), un worker BullMQ qui modifie une Invoice, puis l'appel CacheInvalidator/api/revalidate protégé par secret HMAC. Ajoute un traceparent propagé de la Server Action jusqu'au worker.

Indice : sameSite: 'lax' + double-submit token pour les actions non-GET. Le secret de revalidation doit être comparé en temps constant (crypto.timingSafeEqual), pas avec === (timing attack). Le worker n'a pas de context.active() HTTP : propage le traceparent dans le payload du job.

3. Streaming LLM annulable de bout en bout (implémenter + durcir)

Objectif : streamer des tokens Anthropic depuis Nest jusqu'au navigateur, et prouver qu'un clic sur Stop arrête la facturation tokens côté Nest.

Branche l'endpoint SSE Nest + le Route Handler Next proxy + le ChatBox. Vérifie la chaîne d'annulation : console.log dans le req.on('close') de Nest doit s'afficher à l'instant du clic Stop. Bonus : ajoute un compteur de tokens consommés et montre qu'il s'arrête net.

Indice : le piège est le buffering. Si un proxy (nginx, Cloudflare, ou un return Response qui collecte) bufferise le SSE, l'utilisateur voit tout d'un coup. Headers cache-control: no-transform + relais du ReadableStream brut. L'annulation se propage seulement si tu passes signal: req.signal au fetch upstream.

4. Job IA idempotent et cost-aware (production-grade)

Objectif : une génération longue via BullMQ qui ne facture jamais deux fois, ne retry pas un mauvais prompt, et reprend après crash.

POST /generations accepte un Idempotency-Key, crée un job avec jobId = generationId. Implémente : pas de retry sur 400/invalid_request, retry exponentiel sur 429/529, et job.updateProgress qui checkpoint les tokens partiels en Redis. Tue le worker au milieu (process.exit) et vérifie que la reprise ne régénère pas depuis zéro.

Indice : distingue erreurs retriables et fatales via le type d'erreur du SDK Anthropic (status 429/529 vs 400). Le double POST avec la même Idempotency-Key doit retourner le même generationId (stocke la clé → id en Redis avec TTL). Sans jobId stable, BullMQ crée deux jobs.

5. Casser la frontière, puis la réparer (break-then-fix)

Objectif : reproduire trois pannes classiques du hop hybride et les corriger.

(a) Fuite de données cross-user : mets next: { revalidate: 60 } sur un fetch /me et montre que l'utilisateur B voit les données de A. Corrige avec cache: 'no-store' ou un tag par utilisateur. (b) Pool DB épuisé : enlève le cache sur une liste publique à fort trafic et observe le pool Prisma saturer ; corrige par ISR + tags. (c) Retry storm : active un retry naïf sur 5xx côté Next, fais tomber Nest, observe l'amplification ; corrige avec un circuit breaker (opossum) + AbortSignal.timeout.

Indice : pour (a), le bug est invisible en dev solo — il faut deux sessions. Pour (c), mesure les RPS reçus par Nest pendant la panne avant/après le breaker : la différence doit être un ordre de grandeur.

🎤 En entretien

Q : Pourquoi ne pas tout faire dans Next (API routes + Server Actions) plutôt qu'ajouter Nest ? Parce que la logique métier doit être réutilisable hors HTTP (worker, cron, CLI, mobile), testable sans simuler une requête, et survivre à un remplacement de Next. Next n'a ni DI composable, ni gardes/intercepteurs, ni modèle de queue/worker long-running, ni OpenAPI strict. Au-delà de ~50 endpoints ou dès qu'il y a des jobs, Next API routes deviennent ingérables. La Server Action reste un thin wrapper : parse zod, appelle Nest, revalide.

Q : Comment propages-tu l'auth du navigateur jusqu'à un worker BullMQ déclenché par une requête Next ? Le navigateur ne porte qu'un cookie HttpOnly émis par Nest. Next le relaie serveur-à-serveur (cookies()fetch({ headers: { cookie } })), jamais via localStorage. Pour l'inter-service interne, on peut substituer un JWT court (30 s, clé HMAC partagée) avec des claims actAs/correlationId. Le worker, lui, n'a pas de contexte HTTP : on lui passe le tenantId/userId et le traceparent dans le payload du job, pas dans un header. La validation d'autorité est toujours re-faite côté Nest.

Q : Une page RSC est lente en prod, comment tu débugues le hop Next → Nest ? Je vérifie d'abord que le traceparent W3C se propage : Next et Nest doivent apparaître dans la même trace distribuée. Sans ça, on est aveugle. Ensuite je regarde quatre signaux : latence p99 du hop interne (souvent un N+1 de fetch RSC non parallélisés), cache hit ratio Next (un no-store posé par réflexe tue le cache), saturation du pool DB Nest (lectures publiques non cachées), et taux d'erreur du hop. Le fix le plus fréquent : cacher agressivement les lectures publiques (ISR + tags) et réserver no-store aux données strictement personnelles.

Q : Où mets-tu le secret de l'API LLM et la boucle agentique, et pourquoi ? Côté Nest, jamais dans Next ni le navigateur. Le client Anthropic est injecté via DI (forRootAsync/factory), pas new Anthropic() dans un champ — pour la config par env, le mock en test et le contrôle des retries. La boucle tool-use s'exécute serveur-side (le navigateur ne pilote jamais l'exécution d'outils qui touchent la DB). Next ne fait que proxy/streamer le SSE. Les garde-fous (rate-limit, cost-guard, idempotency-key) vivent au bord côté Nest, et l'annulation client doit se propager jusqu'au stream Anthropic via AbortControllerreq.signalreq.on('close') pour ne pas payer des tokens inutiles.

Bibliothèque tech perso — Achref