Startseite / Artikel

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.

Verlag: REACTAPP.TOP5 Min. Lesezeit1135 Wörter

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

  • api ist der NestJS-Backend.
  • web ist die Next.js-Anwendung.
  • mobile ist 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

  • Scaffolding eines pnpm- und Turborepo-Monorepos für Node.js-Apps Schritt für Schritt: Erstellen Sie ein TypeScript-Monorepo aus einer leeren Datei mit pnpm Workspaces und Turborepo, und führen Sie anschließend Aufgaben für eine Webanwendung, eine API sowie gemeinsame Pakete aus, kompilieren und filtern Sie diese.