Partager du code entre NestJS, Next.js et Expo (React Native) dans un pnpm Turborepo
Créez un monorepo pnpm Turborepo pour une API NestJS, un site Next.js et une application Expo React Native, puis déterminez quels types et schémas doivent être inclus dans les packages partagés.
Sources: Building a Modern Full-Stack Monorepo with Turborepo, NestJS, Next.js, and React Native
Un produit doté d’un backend NestJS, d’une application web Next.js et d’une application mobile Expo (React Native) commence généralement par trois répertoires, ce qui entraîne rapidement des coûts élevés. Chaque modification de type doit être copiée manuellement, les règles de validation existent en trois versions distinctes, et les clients API de chaque application s’éloignent progressivement les uns des autres. Un monorepo Turborepo dans des espaces de travail pnpm permet de maintenir ces trois applications déployables indépendamment tout en fournissant un seul endroit pour le code qui doit être commun à toutes. Ci-dessous se trouve une disposition des espaces de travail pour cette stack ainsi qu’une règle pour déterminer ce qui doit figurer dans les packages partagés ; la question de savoir si Turborepo vaut vraiment la peine d’être ajouté est abordée dans où Turborepo s’intègre dans un monorepo NestJS.
De trois répertoires à un seul espace de travail
Le point de départ est généralement trois projets frères sans aucun lien entre eux :
backend/
frontend/
mobile/
Dans un monorepo, ils deviennent des espaces de travail au sein d’un seul répertoire, gérés par Turborepo et reliés par pnpm :
my-project/
├── apps/
├── packages/
├── turbo.json
└── pnpm-workspace.yaml
Chaque application part désormais d’une seule installation de dépendances, d’un seul ensemble de configurations TypeScript et de lint, ainsi que d’un seul endroit pour le code réutilisable. Sur un petit projet, cela peut sembler superflu. Sur un projet de taille moyenne ou grande, l’élimination des tâches de copie et de synchronisation constitue l’un des plus grands gains en termes de productivité.
Une organisation avec une seule tâche par dossier
Une structure qui s’adapte bien à cette stack :
my-project/
apps/
├── api/
├── web/
└── mobile/
packages/
├── api-client/
├── config/
├── types/
├── utils/
├── validation/
└── ui/
turbo.json
package.json
pnpm-workspace.yaml
Le niveau supérieur sépare les éléments exécutables de ceux réutilisables, et chaque dossier a une seule responsabilité.
apps : programmes pouvant être déployés indépendamment
apicorrespond au backend NestJS.webest l’application Next.js.mobileest l’application Expo (React Native).
Chacun conserve son propre processus de compilation, de configuration et de déploiement ; partager un répertoire ne signifie pas les déployer ensemble.
packages : code sur lequel s’accordent les applications
C’est là que le monorepo présente ses avantages. La logique partagée est écrite une seule fois ici et importée là où elle est nécessaire. Un exemple simple est un type de domaine :
//packages/types
export interface User {
id: string;
name: string;
email: string;
}
Donnez un nom au package, par exemple @repo/types dans son package.json, ajoutez-le en tant que dépendance workspace:* dans chaque application, et chaque application importera la même définition :
import { User } from "@repo/types";
Il n’existe pas de copies parallèles du modèle ni d’interfaces qui évoluent discrètement de manière indépendante. Lorsque User acquiert un champ, le vérificateur de types indique tous les endroits, dans toutes les applications, qui doivent le gérer.
Un schéma de validation unique pour le serveur et les clients
La validation est une autre source classique de duplication. Les mêmes règles de registration apparaissent souvent sous forme de DTO NestJS, d’un validateur de formulaire Next.js et d’un validateur de formulaire React Native, et tôt ou tard l’un d’eux cesse de fonctionner correctement.
Un schéma Zod partagé résout ce problème :
import { z } from "zod";
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
L’API valide les requêtes entrantes en fonction de RegisterSchema, et les deux clients l’utilisent pour la validation des formulaires, ce qui garantit l’application de règles identiques. Lorsque la longueur minimale du mot de passe change, un seul fichier doit être mis à jour. Vous pouvez également dériver le type TypeScript à l’aide de z.infer<typeof RegisterSchema> au lieu de le déclarer séparément. Notez que Zod 4 a introduit des outils de formatage de niveau supérieur tels que z.email() ; la syntaxe enchaînée z.string().email() fonctionne toujours mais est considérée comme obsolète, il convient donc d’adapter son style à la version de Zod utilisée.
Un seul package de client API
Plutôt que que chaque application crée sa propre instance d’Axios, placez-en une dans un package dédié :
//packages/api-client
import axios from "axios";
export const api = axios.create({
baseURL: process.env.API_URL,
});
Toute application cliente l’importe depuis le même endroit :
import { api } from "@repo/api-client";
Les en-têtes d’authentification, les intercepteurs, ainsi que le traitement des tentatives répétées et des erreurs sont définis une seule fois et fonctionnent de la même manière sur le web et sur mobile. Une précaution : process.env.API_URL est lu différemment par chaque framework. Next.js ne met en évidence que les variables préfixées par NEXT_PUBLIC_ dans le code du navigateur, tandis qu’Expo intégre uniquement les variables EXPO_PUBLIC_ dans le bundle de l’application. En pratique, il est plus propre d’exporter une fonction factory qui prend l’URL de base en argument et de permettre à chaque application de transmettre sa propre valeur configurée.
Pourquoi les composants UI sont rarement partagés
Les équipes se demandent souvent si les composants React peuvent être partagés entre Next.js et React Native. Pas directement. L’application web rend des éléments DOM tels que :
<div>
alors que React Native rend des primitives natives telles que :
<View>
Tous deux utilisent React, mais les vocabulaires de composants diffèrent, de sorte qu’un composant écrit pour une plateforme ne s’affiche pas sur l’autre. Si une interface utilisateur multiplateforme est un objectif réel, des bibliothèques telles que Tamagui, Solito ou React Native Web ont été conçues à cette fin et méritent d’être examinées. Sinon, partagez la logique métier, les validations, les hooks, les types et les outils, et conservez l’interface utilisateur de chaque plateforme dans son propre application.
Exécution de l’espace de travail
Turborepo simplifie les commandes quotidiennes. Grâce à un script racine dev qui appelle turbo dev, une seule commande démarre chaque application :
pnpm dev
Pour travailler sur une seule application, utilisez le filtre de pnpm avec le nom du package cible :
pnpm --filter api dev
pnpm --filter web dev
pnpm --filter mobile start
Derrière ces commandes, Turborepo lit le graphe de dépendances, exécute les tâches dans le bon ordre et met en cache les résultats, de sorte que les packages partagés sont compilés avant les applications qui en dépendent, et que les tâches inutiles sont ignorées.
Une règle générale pour déterminer ce qui doit figurer dans les packages
Partagez la logique ; ne forcez pas l’utilisation d’une interface utilisateur commune. Ce type de contenu s’intègre bien entre les applications :
- Les types et enums TypeScript
- Les schémas Zod
- Les fonctions utilitaires et les constantes
- Le client API et les outils d’authentification
- La logique métier
- La configuration partagée (TypeScript, ESLint, formatage)
- Les bibliothèques internes
Les composants UI ne doivent figurer dans un package partagé que si le projet a été délibérément conçu autour d’une solution UI multiplateforme. De même, il convient d’éviter d’inclure dans les packages importés par les applications clients du code réservé au serveur, comme l’accès à la base de données ou la gestion des secrets, car cela pourrait les retrouver dans un bundle destiné au navigateur ou aux appareils mobiles.
Pourquoi les limites sont essentielles
La valeur de cette architecture provient justement de ses limites bien définies. Les applications restent suffisamment indépendantes pour évoluer et être déployées selon leurs propres calendriers, tandis que le contrat entre elles, les types, les schémas ainsi que l’API client se trouvent en un seul endroit. Les modifications se propagent via le vérificateur de types plutôt que par copier-coller, ce qui permet de garder l’espace de travail gérable même à mesure que la base de code s’agrandit.
Lectures complémentaires
- Où Turborepo s’insère dans un monorepo NestJS et quand l’éviter : Comment Turborepo, les espaces de travail pnpm et NestJS répartissent le travail dans un monorepo TypeScript : packages partagés, graphes de tâches, mise en cache, risques de couplage et moments où il convient de l’ignorer.
- Générer automatiquement un client API type-safe Next.js à partir de NestJS Swagger : Apprenez à éliminer les types API redondants en utilisant NestJS Swagger et Orval pour générer automatiquement des hooks React Query type-safe pour Next.js.