Monorepo-Strategien in der Praxis
Realistisches Code-Sharing zwischen React Web und React Native bedeutet nicht, dieselben Komponenten überall zu verwenden, sondern gezielt zu trennen, was plattformunabhängig ist, von dem, was es nicht sein kann. Business-Logik, Typen, API-Clients und Validierung lassen sich sauber teilen, Oberflächenkomponenten meist nicht. Dieser Artikel zeigt eine funktionierende Monorepo-Struktur mit echten Beispielen.
Inhaltsverzeichnis
- 1. Was sich wirklich teilen lässt und was nicht
- 2. Monorepo-Grundstruktur mit pnpm Workspaces
- 3. Geteilte Business-Logik und Custom Hooks
- 4. Geteilte Typen und API-Clients
- 5. Plattformspezifische Dateien für die UI-Schicht
- 6. Styling teilen mit NativeWind
- 7. React Native Web als Sonderfall
- 8. Fallstricke bei geteiltem Code
- 9. Strategien im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was sich wirklich teilen lässt und was nicht
Der häufigste Irrtum beim Versuch, Code zwischen React Web und React Native zu teilen, ist die Annahme, komplette Oberflächenkomponenten ließen sich unverändert wiederverwenden. Das funktioniert nicht, weil beide Plattformen unterschiedliche Basiselemente verwenden: div und span im Web gegenüber View und Text in React Native. Wer versucht, diese Ebene zu teilen, produziert entweder Wrapper-Abstraktionen, die beide Welten schlechter bedienen als native Implementierungen, oder gibt das Vorhaben nach kurzer Zeit frustriert auf.
Realistisches Code-Sharing zielt stattdessen auf die Schichten unterhalb der Präsentation: Business-Logik, Zustandsverwaltung, API-Kommunikation, Validierungsregeln und TypeScript-Typen. Diese Schichten haben keinerlei Abhängigkeit zu View, div oder anderen plattformspezifischen Elementen und lassen sich zu nahezu 100 Prozent teilen. In gut strukturierten Projekten liegt der geteilte Code-Anteil oft bei 60 bis 80 Prozent der Gesamtlogik, während die tatsächliche Präsentationsschicht plattformspezifisch bleibt.
Diese Trennung ist keine Einschränkung, sondern folgt derselben Architektur, die auch ohne Cross-Platform-Ambitionen sinnvoll wäre: Eine klare Trennung zwischen Domänenlogik und Präsentation. Wer sein React-Web-Projekt bereits so strukturiert hat, findet beim Einstieg in React Native überraschend wenig Widerstand, weil die geteilten Bausteine bereits isoliert vorliegen.
2. Monorepo-Grundstruktur mit pnpm Workspaces
Die technische Grundlage für Code-Sharing ist fast immer ein Monorepo mit pnpm Workspaces oder Yarn Workspaces, orchestriert durch Turborepo für schnelle, gecachte Builds. Die Struktur trennt apps/ für die eigenständigen Anwendungen, also die Web-App und die React Native-App, von packages/ für geteilte Bibliotheken wie Business-Logik, Typen und Design-Tokens. Jedes Package im packages/-Ordner hat seine eigene package.json und kann unabhängig versioniert und getestet werden.
Turborepo cached Build- und Test-Ergebnisse pro Package, sodass ein Änderung in der React Native-App keinen erneuten Build der unveränderten Web-App auslöst. Bei wachsenden Projekten ist dieser Caching-Mechanismus keine Kür, sondern notwendig, um CI-Laufzeiten unter Kontrolle zu halten. Die Konfiguration in turbo.json definiert die Abhängigkeitsgraphen zwischen den Tasks, sodass etwa build in apps/mobile automatisch zuerst build in packages/shared-logic ausführt.
# Monorepo layout for sharing code between React web and React Native
myapp/
apps/
web/ # React web app (Vite or Next.js)
mobile/ # React Native app (Expo)
packages/
shared-logic/ # Business logic, hooks, validation
shared-types/ # TypeScript types and API contracts
api-client/ # Fetch wrapper and endpoint definitions
package.json
pnpm-workspace.yaml
turbo.json
# Install and link all workspace packages
pnpm install
# Run mobile app dev server, shared packages auto-linked
pnpm --filter mobile dev
3. Geteilte Business-Logik und Custom Hooks
Custom Hooks ohne DOM-Abhängigkeit sind der ergiebigste Bereich für Code-Sharing zwischen React Web und React Native. Ein Hook, der Formularvalidierung implementiert, Warenkorb-Berechnungen durchführt oder Daten mit TanStack Query abfragt, enthält in der Regel keinen einzigen Verweis auf document, window oder ein natives Element. Solche Hooks wandern unverändert in ein geteiltes Package und werden von beiden Apps importiert.
Ein konkretes Beispiel ist ein Hook zur Warenkorbverwaltung, der Mengenänderungen, Preisberechnung und Rabattlogik kapselt. Diese Logik ist in einer E-Commerce-App auf Web und Mobile identisch, nur die Darstellung der Ergebnisse unterscheidet sich. Durch die Auslagerung in packages/shared-logic wird ein Bugfix oder eine neue Rabattregel nur an einer Stelle implementiert und automatisch auf beiden Plattformen wirksam, ohne Gefahr, dass Web und Mobile mit der Zeit auseinanderlaufen.
// packages/shared-logic/src/useCart.ts
// Platform-agnostic cart logic, shared between web and React Native
import { useState, useCallback, useMemo } from 'react';
import type { CartItem, Product } from '@myapp/shared-types';
export function useCart() {
const [items, setItems] = useState<CartItem[]>([]);
const addItem = useCallback((product: Product, quantity = 1) => {
setItems((prev) => {
const existing = prev.find((i) => i.productId === product.id);
if (existing) {
return prev.map((i) =>
i.productId === product.id
? { ...i, quantity: i.quantity + quantity }
: i
);
}
return [...prev, { productId: product.id, quantity, price: product.price }];
});
}, []);
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
);
return { items, addItem, total };
}
4. Geteilte Typen und API-Clients
TypeScript-Typen für API-Antworten, Domänenmodelle und Formularschemata sollten immer in einem gemeinsamen Package liegen, unabhängig davon, ob sonst viel oder wenig Code geteilt wird. Eine geteilte Product-Typdefinition verhindert, dass Web und React Native mit der Zeit inkompatible Annahmen über die Datenstruktur treffen, ein Problem, das sonst erst spät und schwer nachvollziehbar in der Produktion auffällt.
Der API-Client selbst, meist ein dünner Wrapper um fetch mit Fehlerbehandlung und Retry-Logik, lässt sich ebenfalls vollständig teilen, solange er keine plattformspezifische Authentifizierung enthält. Wichtig ist, den Zugriff auf sicheren Speicher, etwa AsyncStorage in React Native gegenüber localStorage im Web, über eine gemeinsame Schnittstelle zu abstrahieren, deren konkrete Implementierung pro Plattform ausgetauscht wird. So bleibt der API-Client selbst plattformunabhängig, während nur die darunterliegende Speicherimplementierung variiert.
5. Plattformspezifische Dateien für die UI-Schicht
Für die Präsentationsschicht, die naturgemäß nicht identisch geteilt werden kann, bietet der React-Native-Bundler Metro einen eleganten Mechanismus: Dateiendungen wie .native.tsx und .web.tsx. Eine Datei namens ProductCard.native.tsx wird automatisch nur für React Native aufgelöst, ProductCard.web.tsx nur für die Web-App, während beide über denselben Import-Pfad ./ProductCard angesprochen werden. Der Aufrufcode muss dadurch keine Verzweigungslogik enthalten.
Dieser Mechanismus funktioniert am saubersten, wenn beide Varianten dieselbe Props-Schnittstelle implementieren, definiert in einem geteilten Typ aus packages/shared-types. So bleibt sichergestellt, dass die Web- und die native Variante einer Komponente austauschbar bleiben, ohne dass Aufrufer wissen müssen, auf welcher Plattform sie gerade laufen. Bei Next.js-Projekten übernimmt ein zusätzliches Babel- oder Webpack-Setup dieselbe Auflösung, da Next.js standardmäßig nicht dieselbe Plattformdatei-Konvention wie Metro kennt.
// ProductCard.native.tsx - resolved automatically inside the Expo app
import { View, Text, Pressable } from 'react-native';
import type { ProductCardProps } from '@myapp/shared-types';
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
return (
<Pressable onPress={() => onAddToCart(product)}>
<View style={{ padding: 16 }}>
<Text style={{ fontWeight: '600' }}>{product.name}</Text>
<Text>{product.price.toFixed(2)} EUR</Text>
</View>
</Pressable>
);
}
// ProductCard.web.tsx - resolved automatically inside the Vite/Next.js app
export function ProductCardWeb({ product, onAddToCart }: ProductCardProps) {
return (
<button onClick={() => onAddToCart(product)} className="p-4 text-left">
<p className="font-semibold">{product.name}</p>
<p>{product.price.toFixed(2)} EUR</p>
</button>
);
}
6. Styling teilen mit NativeWind
NativeWind übersetzt Tailwind-CSS-Klassennamen in React Native-Style-Objekte zur Build-Zeit und ist damit der praktikabelste Weg, ein einheitliches Design-System über Web und Mobile hinweg zu pflegen. Statt zwei völlig getrennte Styling-Ansätze zu warten, CSS für Web und StyleSheet-Objekte für Native, schreiben Teams mit NativeWind dieselben Utility-Klassen in beiden Umgebungen, etwa className="p-4 bg-slate-900 rounded-xl".
Design-Tokens wie Farben, Abstände und Typografie lassen sich in einer gemeinsamen Tailwind-Konfiguration in packages/design-tokens definieren und von beiden Apps referenzieren. Das verhindert Design-Drift zwischen Web und Mobile, ein Problem, das in Cross-Platform-Teams ohne geteiltes Token-System schleichend entsteht, weil kleine Anpassungen an Farbwerten in der einen App vorgenommen werden, ohne die andere zu aktualisieren.
7. React Native Web als Sonderfall
React Native Web verfolgt den umgekehrten Ansatz: Statt React-Web-Komponenten in React Native zu übernehmen, kompiliert diese Bibliothek React Native-Komponenten wie View und Text zu DOM-Elementen im Browser. Dieser Ansatz eignet sich besonders für Teams, die primär mobil denken und die Web-App als zusätzliche Ausgabeplattform derselben Codebasis behandeln wollen, etwa bei Expo-Projekten mit expo start --web.
Der Kompromiss dabei: Das resultierende Web-Markup ist oft weniger semantisch als handgeschriebenes HTML, und SEO-kritische Seiten profitieren meist mehr von einer echten Web-first-Lösung mit Next.js. React Native Web eignet sich hervorragend für interne Tools, Admin-Dashboards und Anwendungen ohne SEO-Anforderungen, ist aber selten die richtige Wahl für öffentliche, suchmaschinenoptimierte Marketing-Seiten.
8. Fallstricke bei geteiltem Code
Ein häufiger Fallstrick ist das versehentliche Einschleusen von plattformspezifischem Code in ein eigentlich geteiltes Package, etwa ein import von react-native-async-storage in einem Hook, der auch von der Web-App genutzt werden soll. Solche Abhängigkeiten brechen den Build der Web-App sofort und sind meist erst durch einen fehlgeschlagenen CI-Lauf sichtbar, nicht durch lokale Entwicklung, wenn nur an der mobilen App gearbeitet wird.
Ein zweiter Fallstrick betrifft übermäßigen Ehrgeiz beim Teilen: Der Versuch, auch die Präsentationsschicht mit generischen Wrapper-Komponenten zu vereinheitlichen, führt regelmäßig zu Abstraktionen, die weder für Web noch für React Native optimal sind. Die Faustregel lautet, Logik konsequent zu teilen und Präsentation konsequent plattformspezifisch zu halten, statt beides zu vermischen.
9. Strategien im direkten Vergleich
Die folgende Übersicht zeigt, welche Code-Sharing-Strategie für welchen Anwendungsfall zwischen React Web und React Native am besten geeignet ist.
| Schicht | Teilbarkeit | Empfohlene Strategie | Grenzfall |
|---|---|---|---|
| Business-Logik, Hooks | Fast 100 Prozent | Gemeinsames Package, kein Plattform-Code | Speicherzugriff abstrahieren |
| Typen und Verträge | 100 Prozent | Gemeinsames Types-Package | Keiner |
| Präsentationskomponenten | Gering | Plattformdateien .native/.web | Gleiche Props-Schnittstelle |
| Styling | Mittel | NativeWind mit geteilten Tokens | Nicht jede CSS-Feature verfügbar |
| Ganze Oberfläche | Nur bei internen Tools | React Native Web | Schwächer bei SEO-Anforderungen |
Diese Übersicht zeigt: Ein pauschales Ziel von hundert Prozent geteiltem Code ist unrealistisch und meist auch nicht sinnvoll. Der pragmatische Ansatz teilt konsequent, was teilbar ist, und akzeptiert plattformspezifische Präsentation als bewusste Designentscheidung, nicht als Kompromiss.
Mironsoft
Monorepo-Architektur für Web- und React-Native-Teams
Web und Mobile aus einer Codebasis pflegen?
Wir bauen euer Turborepo-Monorepo auf, trennen geteilte Business-Logik sauber von plattformspezifischer Präsentation und richten CI-Pipelines für Web und React Native gemeinsam ein.
Monorepo-Setup
Turborepo, pnpm Workspaces und geteilte Packages von Grund auf
Logik-Extraktion
Bestehende Business-Logik aus Web-Only-Code plattformunabhängig auslagern
Design-System
NativeWind-Setup mit geteilten Design-Tokens für Web und Mobile
10. Zusammenfassung
Erfolgreiches Code-Sharing zwischen React Web und React Native beruht auf einer klaren Schichtentrennung: Business-Logik, Typen und API-Clients wandern nahezu vollständig in geteilte Packages eines Monorepos, während die Präsentationsschicht bewusst plattformspezifisch bleibt, unterstützt durch Metros Dateiendungs-Konvention .native.tsx und .web.tsx. Turborepo und pnpm Workspaces liefern die technische Basis für schnelle, gecachte Builds über mehrere Apps hinweg.
Wer diese Trennung konsequent umsetzt, vermeidet die typischen Fallstricke von Cross-Platform-Projekten: plattformspezifische Abhängigkeiten in geteiltem Code und überambitionierte Präsentations-Abstraktionen. NativeWind und geteilte Design-Tokens schließen die Lücke beim Styling, während React Native Web für interne Tools ohne SEO-Anforderungen eine sinnvolle Ergänzung sein kann.
Code teilen zwischen React Web und React Native, das Wichtigste auf einen Blick
Monorepo
Turborepo mit pnpm Workspaces trennt Apps von geteilten Packages und cached Builds pro Package.
Geteilte Logik
Custom Hooks, Typen und API-Clients ohne DOM-Abhängigkeit lassen sich nahezu vollständig teilen.
Plattformdateien
.native.tsx und .web.tsx lösen automatisch die richtige UI-Variante auf, gleiche Props-Schnittstelle.
Styling
NativeWind mit geteilten Design-Tokens verhindert Design-Drift zwischen Web und Mobile.