Accueil / Articles

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.

Éditeur : REACTAPP.TOP5 min de lecture1135 mots

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

  • api correspond au backend NestJS.
  • web est l’application Next.js.
  • mobile est 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

  • Créer un monorepo pnpm et Turborepo pour des applications Node.js, étape par étape : Mettre en place un monorepo TypeScript à partir d’un dossier vide en utilisant les espaces de travail pnpm et Turborepo, puis exécuter, compiler et filtrer les tâches pour une application web, une API et des packages partagés.