Code teilen zwischen NestJS, Next.js und Expo (React Native) in einem pnpm Turborepo
Erstellen Sie ein pnpm Turborepo Monorepo für eine NestJS-API, eine Next.js-Website und eine Expo React Native-App und entscheiden Sie, welche Typen und Schemata in gemeinsame Pakete aufgenommen werden sollen.
Quellen: Building a Modern Full-Stack Monorepo with Turborepo, NestJS, Next.js, and React Native
Ein Produkt mit einem NestJS-Backend, einer Next.js-Webanwendung und einer Expo (React Native)-Mobile-App beginnt in der Regel als drei separate Repositorien, wodurch die Kosten schnell ansteigen. Veränderte Typen müssen manuell kopiert werden, Validierungsregeln existieren in drei Versionen, und der API-Client jeder Anwendung entwickelt sich allmählich unabhängig von den anderen weiter. Ein Turborepo-Monorepo in pnpm-Workspaces sorgt dafür, dass die drei Anwendungen weiterhin eigenständig bereitgestellt werden können, während sie gleichzeitig einen gemeinsamen Ort für den Code erhalten, über den sie sich einig sein sollten. Unten finden Sie eine Aufteilung des Workspaces für diese Technologiestack sowie eine Regel zur Bestimmung dessen, was in gemeinsame Pakete gehört; ob Turborepo überhaupt sinnvoll ist, wird in where Turborepo fits in a NestJS monorepo erläutert.
Von drei Repositorien zu einem Workspace
Der Ausgangspunkt sind in der Regel drei miteinander verbundene Projekte, die durch nichts miteinander verbunden sind:
backend/
frontend/
mobile/
In einem Monorepo werden sie zu Arbeitsräumen in einem einzigen Repository, das von Turborepo gesteuert wird und über pnpm miteinander verbunden ist:
my-project/
├── apps/
├── packages/
├── turbo.json
└── pnpm-workspace.yaml
Jede App teilt nun eine einzige Abhängigkeitsinstallation, eine einzige Konfiguration für TypeScript und Lint sowie einen einzigen Ort für wiederverwendbaren Code. Bei kleinen Projekten kann das wie eine Formalität wirken. Bei mittleren oder großen Projekten ist die Beseitigung der Arbeit mit Kopieren und Synchronisieren einer der größten Produktivitätsvorteile überhaupt.
Eine Struktur mit einer Aufgabe pro Ordner
Eine für diesen Technologiestack gut skalierbare Struktur:
my-project/
apps/
├── api/
├── web/
└── mobile/
packages/
├── api-client/
├── config/
├── types/
├── utils/
├── validation/
└── ui/
turbo.json
package.json
pnpm-workspace.yaml
Die oberste Ebene trennt ausführbare Komponenten von wiederverwendbaren Komponenten, und jede Ordnerstruktur hat eine einzige Verantwortung.
apps: unabhängig einsetzbare Programme
apiist der NestJS-Backend.webist die Next.js-Anwendung.mobileist die Expo (React Native)-App.
Jeder Ordner behält seine eigene Build-, Konfigurations- und Deployment-Pipeline bei; das Teilen eines Repositoriums bedeutet nicht, dass alles zusammen bereitgestellt wird.
packages: Code, auf dem sich die Apps einigen
Hier zeigt sich der Vorteil eines Monorepos. Gemeinsame Logik wird hier einmal geschrieben und überall importiert, wo sie benötigt wird. Ein einfaches Beispiel ist ein Domain-Typ:
//packages/types
export interface User {
id: string;
name: string;
email: string;
}
Geben Sie dem Paket in seinem package.json einen Namen wie @repo/types, fügen Sie es als workspace:*-Abhängigkeit in jede Anwendung hinzu, und jede Anwendung importiert dieselbe Definition:
import { User } from "@repo/types";
Es gibt keine parallelen Kopien des Modells sowie keine Interfaces, die sich heimlich unterscheiden. Wenn User ein Feld erhält, weist der Typprüfer auf jeden Ort in jeder Anwendung hin, der damit umgehen muss.
Ein einziges Validierungschema für Server und Clients
Die Validierung ist eine weitere klassische Quelle für Duplikationen. Dasselbe Registrierungsregelwerk taucht oft als NestJS DTO, als Next.js-Formvalidator sowie als React Native-Formvalidator auf – früher oder später passt einer davon nicht mehr dazu.
Ein gemeinsames Zod-Schema beseitigt dieses Problem:
import { z } from "zod";
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
Die API überprüft eingehende Anfragen anhand von RegisterSchema, und beide Clients verwenden es zur Formvalidierung, sodass alle identische Regeln anwenden. Wenn sich die Mindestlänge des Passworts ändert, muss nur eine Datei geändert werden. Sie können den TypeScript-Typ auch mit z.infer<typeof RegisterSchema> ableiten, anstatt ihn separat zu deklarieren. Beachten Sie, dass Zod 4 oberste Ebene-Formathilfen wie z.email() eingeführt hat; die verschachtelte Form z.string().email() funktioniert zwar weiterhin, ist dort aber als veraltet gekennzeichnet, daher sollten Sie den Stil Ihrer Zod-Version anpassen.
Ein einziges API-Client-Paket
Anstatt jede Anwendung ihre eigene Axios-Instanz zu erstellen, platzieren Sie diese in einem speziellen Paket:
//packages/api-client
import axios from "axios";
export const api = axios.create({
baseURL: process.env.API_URL,
});
Jede Client-App importiert es von derselben Stelle:
import { api } from "@repo/api-client";
Auth-Header, Interceptor, Wiederholungs- und Fehlerbehandlung werden einmal definiert und verhalten sich sowohl im Web als auch auf Mobilgeräten gleich. Eine Einschränkung: process.env.API_URL wird von jedem Framework unterschiedlich gelesen. Next.js stellt nur Variablen mit dem Präfix NEXT_PUBLIC_ dem Browsercode zur Verfügung, während Expo nur EXPO_PUBLIC_-Variablen in den App-Bundle einbindet. In der Praxis ist es sauberer, eine Factory zu exportieren, die die Basis-URL als Argument entgegennimmt, und es jedem App zu überlassen, seinen eigenen konfigurierten Wert zu übergeben.
Warum UI-Komponenten selten übertragen werden
Teams fragen oft, ob React-Komponenten zwischen Next.js und React Native geteilt werden können. Nicht direkt. Die Web-App rendernt DOM-Elemente wie zum Beispiel:
<div>
während React Native native Primitive wie folgende darstellt:
<View>
Sowohl Plattformen verwenden React, doch die Komponenten-Vokabulare unterscheiden sich, wodurch eine für eine Plattform geschriebene Komponente auf der anderen nicht angezeigt wird. Wenn eine plattformübergreifende Benutzeroberfläche das eigentliche Ziel ist, wurden dafür Bibliotheken wie Tamagui, Solito oder React Native Web entwickelt, die es wert sind, geprüft zu werden. Andernfalls sollte die Geschäftslogik, die Validierung, Hooks, Typen sowie Hilfsfunktionen geteilt werden, während die Benutzeroberfläche jeder Plattform in einer eigenen App bleibt.
Ausführung der Arbeitsumgebung
Turborepo macht die täglichen Befehle einfach. Mit einem Root-dev-Skript, das turbo dev aufruft, startet ein einziger Befehl alle Anwendungen:
pnpm dev
Um an einer einzigen Anwendung zu arbeiten, verwenden Sie den Filter von pnpm mit dem Namen des Ziel-Pakets:
pnpm --filter api dev
pnpm --filter web dev
pnpm --filter mobile start
Hinter diesen Befehlen liest Turborepo das Abhängigkeitsdiagramm, führt die Aufgaben in der richtigen Reihenfolge aus und speichert die Ergebnisse, sodass gemeinsam genutzte Pakete vor den darauf angewiesenen Anwendungen kompiliert werden und unveränderte Arbeit übersprungen wird.
Ein Faustregel für das, was in Paketen enthalten sein sollte
Teilen Sie Logik – zwingen Sie nicht zu einer gemeinsamen Benutzeroberfläche. Folgende Elemente eignen sich gut für den Austausch zwischen Anwendungen:
- TypeScript-Typen und Enums
- Zod-Schemata
- Hilfsfunktionen und Konstanten
- Der API-Client und Authentifizierungs-Hilfsprogramme
- Business-Logik
- Gemeinsame Konfiguration (TypeScript, ESLint, Formatierung)
- Interne Bibliotheken
UI-Komponenten gehören nur in ein gemeinsames Paket, wenn das Projekt absichtlich auf einer plattformübergreifenden UI-Lösung basiert. Halten Sie außerdem Code, der nur auf dem Server ausgeführt wird – wie Datenbankzugriffe oder das Verarbeiten von Geheimnissen – außerhalb der Pakete, die von Client-Apps importiert werden, da er sonst in einem Browser- oder Mobil-Bundle landen könnte.
Warum die Grenzen wichtig sind
Der Wert dieser Struktur ergibt sich aus ihren Grenzen. Die Apps bleiben weitgehend unabhängig, sodass sie nach eigenem Zeitplan weiterentwickelt und bereitgestellt werden können, während der Vertrag zwischen ihnen, die Typen, Schemata sowie der API-Client genau an einem Ort gespeichert sind. Änderungen werden über den Typprüfer übertragen anstelle von Kopieren und Einfügen, und die Arbeitsumgebung bleibt übersichtlich, auch wenn die Codebasis wächst.
Weitere Literatur
- Wo Turborepo in einem NestJS Monorepo Platz findet und wann man darauf verzichten sollte: Wie Turborepo, pnpm Workspaces und NestJS die Arbeit in einem TypeScript Monorepo aufteilen: gemeinsame Pakete, Task-Graphen, Caching, Kopplungsrisiken und wann man darauf verzichten sollte.
- Automatische Erstellung eines typsicheren Next.js API Clients aus NestJS Swagger: Erfahren Sie, wie Sie durch die Verwendung von NestJS Swagger und Orval duplizierte API-Typen beseitigen und typsichere React Query Hooks für Next.js automatisch erzeugen können.