Monorepo with Shared Schemas: Mobile + Backend in Turborepo
How we structured a Turborepo monorepo with React Native mobile and NestJS backend sharing Zod schemas, TypeScript types, and validation logic to eliminate API contract drift.
Tags
Monorepo with Shared Schemas: Mobile + Backend in Turborepo
TL;DR
I structured a Turborepo monorepo housing a React Native/Expo mobile app and a NestJS backend that share Drizzle ORM schemas, Zod validation schemas, TypeScript types, and email templates through internal packages. The result: API contract mismatches caught at build time instead of production, and a single pnpm dev command to run the entire stack. This post covers the monorepo structure, the shared package architecture, Metro bundler configuration headaches, and why Drizzle ORM was the right fit for cross-package schema sharing.
The Challenge
The project was a mobile-first platform with a React Native app (built with Expo) and a NestJS API backend. Initially, the codebase was split across two separate repositories. The mobile team defined their own TypeScript interfaces for API responses, and the backend team had their own DTOs. Predictably, these definitions drifted apart over time.
The symptoms were painful:
- ›Silent runtime failures — the mobile app expected a field called
createdAt(camelCase) but the API returnedcreated_at(snake_case) after a backend refactor. No build error. No test failure. Just a blank screen in production. - ›Duplicated validation logic — email format validation, password strength rules, and date range checks were implemented separately on both sides, with subtle differences.
- ›Email template drift — transactional email templates lived in the backend repo, but the mobile team needed to preview them during development. Copy-pasting templates between repos led to inconsistencies.
- ›Slow onboarding — new developers had to clone two repos, configure two sets of environment variables, and mentally map how types in one repo corresponded to types in the other.
The goal was to consolidate everything into a single monorepo with shared packages that both the mobile app and the backend consumed as internal dependencies, with type safety enforced across the boundary.
The Architecture
Monorepo Structure with Turborepo
I chose Turborepo as the monorepo tool because it provides fast, incremental builds with caching, works well with pnpm workspaces, and has a minimal learning curve compared to Nx. The directory structure:
root/
├── apps/
│ ├── mobile/ # React Native + Expo app
│ └── api/ # NestJS backend
├── packages/
│ ├── shared-schemas/ # Drizzle ORM schemas + Zod validators
│ ├── shared-types/ # TypeScript type definitions
│ ├── shared-email/ # React Email templates
│ └── shared-utils/ # Common utility functions
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
The pnpm-workspace.yaml declares the workspace packages:
packages:
- "apps/*"
- "packages/*"Each shared package has its own package.json with a name scoped under @acme/:
{
"name": "@acme/shared-schemas",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
}
}Notice that main and types point directly to TypeScript source files. This is intentional — Turborepo's internal packages can be consumed as raw TypeScript by both consumers, avoiding a separate build step for shared packages. The consuming apps handle transpilation through their own build pipelines.
Shared Drizzle ORM Schemas
Drizzle ORM was the key enabler for cross-package schema sharing. Unlike Prisma, which generates a client tied to a specific output directory, Drizzle schemas are plain TypeScript objects that can be defined anywhere and imported by any package. This makes them naturally monorepo-friendly.
The shared-schemas package defines all database tables:
// packages/shared-schemas/src/tables/users.ts
import { pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 255 }).notNull().unique(),
fullName: varchar("full_name", { length: 255 }).notNull(),
role: text("role", { enum: ["admin", "user", "moderator"] })
.notNull()
.default("user"),
avatarUrl: text("avatar_url"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Drizzle infers types directly from the schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;The backend uses these schemas directly with Drizzle's query builder:
// apps/api/src/users/users.service.ts
import { users, type NewUser } from "@acme/shared-schemas";
import { eq } from "drizzle-orm";
@Injectable()
export class UsersService {
constructor(@Inject("DATABASE") private db: DrizzleDatabase) {}
async findByEmail(email: string) {
const result = await this.db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return result[0] ?? null;
}
async create(data: NewUser) {
const [created] = await this.db
.insert(users)
.values(data)
.returning();
return created;
}
}The mobile app imports the same User type to ensure API response parsing matches the database schema exactly:
// apps/mobile/src/api/users.ts
import type { User } from "@acme/shared-schemas";
export async function fetchCurrentUser(): Promise<User> {
const response = await api.get("/users/me");
return userResponseSchema.parse(response.data);
}If a backend developer renames fullName to displayName in the shared schema, the mobile app's TypeScript compilation fails immediately — not in production, not in QA, but in the developer's terminal before they can even push the commit.
Zod Validation Schemas
Alongside Drizzle schemas, the shared-schemas package exports Zod schemas for request/response validation:
// packages/shared-schemas/src/validators/user.validators.ts
import { z } from "zod";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { users } from "../tables/users";
// Auto-generate Zod schemas from Drizzle table definitions
export const insertUserSchema = createInsertSchema(users, {
email: z.string().email("Invalid email format"),
fullName: z.string().min(2, "Name must be at least 2 characters"),
});
export const selectUserSchema = createSelectSchema(users);
// Custom schemas for specific endpoints
export const updateProfileSchema = z.object({
fullName: z.string().min(2).optional(),
avatarUrl: z.string().url().nullable().optional(),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;NestJS uses these in validation pipes:
// apps/api/src/users/users.controller.ts
import { updateProfileSchema } from "@acme/shared-schemas";
@Patch("profile")
async updateProfile(
@Body(new ZodValidationPipe(updateProfileSchema)) body: UpdateProfileInput,
@CurrentUser() user: User,
) {
return this.usersService.updateProfile(user.id, body);
}React Native uses the same schemas for form validation:
// apps/mobile/src/screens/EditProfileScreen.tsx
import { updateProfileSchema, type UpdateProfileInput } from "@acme/shared-schemas";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function EditProfileScreen() {
const { control, handleSubmit, formState } = useForm<UpdateProfileInput>({
resolver: zodResolver(updateProfileSchema),
});
// Same validation rules, same error messages, both sides
}Shared Email Templates
Transactional emails were built with React Email, which lets you author email templates as React components. These live in the shared-email package:
// packages/shared-email/src/templates/WelcomeEmail.tsx
import { Html, Head, Body, Container, Text, Button } from "@react-email/components";
interface WelcomeEmailProps {
fullName: string;
verificationUrl: string;
}
export function WelcomeEmail({ fullName, verificationUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Body style={bodyStyle}>
<Container style={containerStyle}>
<Text style={headingStyle}>Welcome, {fullName}!</Text>
<Text>Please verify your email to get started.</Text>
<Button href={verificationUrl} style={buttonStyle}>
Verify Email
</Button>
</Container>
</Body>
</Html>
);
}The backend renders these to HTML for sending, and the mobile team can preview templates locally using React Email's dev server — both consuming the same source of truth.
Metro Bundler Configuration
This was the single most frustrating part of the entire setup. React Native's Metro bundler does not natively resolve pnpm workspace symlinks or packages outside the app's root directory. Without configuration, importing @acme/shared-schemas from the mobile app simply fails with "Module not found."
The fix involves two things: telling Metro where to find the workspace packages, and ensuring it can resolve their dependencies:
// apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
// Watch the shared packages for changes
config.watchFolders = [monorepoRoot];
// Resolve packages from both the app's node_modules and the root
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, "node_modules"),
];
// Ensure we don't bundle duplicate React instances
config.resolver.disableHierarchicalLookup = true;
module.exports = config;I also had to add expo-modules-core resolution to prevent duplicate React Native instances when shared packages pulled in different versions through transitive dependencies. This required explicit resolutions in the root package.json.
Turborepo Pipeline Configuration
The turbo.json defines build dependencies between packages:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^typecheck"]
},
"db:push": {
"cache": false
}
}
}The ^build syntax means each package waits for its dependencies to build first. The typecheck task cascades through the dependency graph, so changing a shared schema triggers type-checking in both the mobile app and the API.
Key Decisions & Trade-offs
Drizzle ORM over Prisma: Prisma's generated client is tied to a specific output path and runtime, making it awkward to share across packages. Drizzle schemas are plain TypeScript — they import and export like any other code. The trade-off is that Drizzle's migration tooling is less mature, but for schema sharing, it was the clear winner.
Raw TypeScript internal packages vs. pre-built packages: Pointing main directly to .ts files means no build step for shared packages, but it couples consumers to the same TypeScript configuration. We enforced a shared tsconfig.base.json at the root to keep this consistent.
pnpm over npm/yarn: pnpm's strict dependency resolution prevents phantom dependencies (packages that work by accident because they're hoisted). This strictness caused initial setup pain but prevented subtle bugs where the mobile app accidentally imported a package it didn't explicitly depend on.
Zod for runtime validation, Drizzle for schema definition: Having two schema systems (Drizzle for DB, Zod for validation) creates some redundancy. drizzle-zod bridges this gap by generating Zod schemas from Drizzle tables, but custom validation rules still need manual Zod schemas. The alternative — a single schema system for everything — doesn't exist yet in the TypeScript ecosystem.
Shared email templates as a separate package: Email templates could have lived in the API, but extracting them let the mobile team preview emails locally without running the backend. This improved cross-team collaboration noticeably.
Results & Outcomes
After migrating to the monorepo, API contract mismatches were caught at build time through TypeScript's type system and Turborepo's cascading typecheck pipeline. The shared Zod schemas eliminated the entire class of validation discrepancy bugs — the mobile app and backend now reject the same invalid inputs with the same error messages. Developer onboarding dropped from a multi-step process involving two repos to a single git clone followed by pnpm install && pnpm dev. The shared email templates gave both teams a consistent view of transactional emails, and changes to email designs propagated automatically without manual syncing.
What I'd Do Differently
Use tsconfig project references from the start. I initially relied on Turborepo's pipeline for build ordering, but TypeScript project references provide faster incremental compilation and better IDE support for cross-package navigation. Adding them retroactively required touching every tsconfig.json.
Set up Changesets for versioning earlier. Internal packages at version 0.0.0 work fine in development, but when we needed to track which version of the shared schemas was deployed to production, we had no versioning history. Changesets would have provided this with minimal overhead.
Standardize on a single test runner. The mobile app used Jest with React Native preset, the API used Jest with a NestJS preset, and the shared packages used vitest. Three different test configurations in one repo was unnecessary complexity. I would pick vitest for everything and configure it per-package.
Add a CI check for circular dependencies. As the number of shared packages grew, we nearly introduced a circular import between shared-schemas and shared-utils. A tool like madge in CI would have caught this before it became a problem.
FAQ
Why share schemas between mobile and backend?
When mobile and backend define their own types independently, API contracts drift over time. A backend developer renames a field, adds a new required property, or changes a type from string to number, and the mobile app continues compiling just fine — because it's referencing its own local type definitions. The bug only surfaces at runtime, often in production, when the mobile app tries to read a field that no longer exists or parses a value in the wrong format. Shared schemas create a single source of truth: one Drizzle table definition generates one TypeScript type that both sides import. If the backend changes the schema, the mobile app's TypeScript compilation fails immediately, forcing the developer to update both consumers before the change can be merged. This shifts the failure point from "user reports a crash" to "CI rejects the pull request."
How do Zod schemas work across mobile and backend?
Zod schemas defined in the @acme/shared-schemas package are imported by both the NestJS API and the React Native app. On the backend, Zod schemas are used inside NestJS validation pipes — incoming request bodies are parsed through schema.parse(body), and invalid data throws a structured validation error with field-level messages. On the mobile side, the same Zod schemas are used with react-hook-form's zodResolver to validate form inputs before submission, and with .parse() to validate API responses after fetching. This means a validation rule like "email must be a valid format" or "name must be at least 2 characters" is defined exactly once and enforced identically on both sides. When a validation rule changes — say, minimum password length increases from 8 to 12 — updating the Zod schema in the shared package automatically updates validation on both the backend and the mobile app.
What are the challenges of React Native in a monorepo?
React Native's Metro bundler is the primary challenge. Metro was designed for standalone React Native projects, not monorepos with workspace packages. It does not follow symlinks by default (which pnpm relies on), it does not resolve packages outside the app's root directory, and it can bundle duplicate copies of React if multiple node_modules paths contain the same package. The fix requires explicit metro.config.js configuration: watchFolders must include the monorepo root so Metro can find shared packages, nodeModulesPaths must list resolution directories in the correct order, and disableHierarchicalLookup should be enabled to prevent Metro from accidentally resolving packages from the wrong node_modules. Beyond Metro, Expo's prebuild system needs the shared packages to be listed in the app's package.json dependencies, and EAS Build needs the monorepo root's package.json to include a postinstall script that ensures all workspace dependencies are linked correctly before the native build starts.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
Optimizing Core Web Vitals for e-Commerce
Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.
Building an AI-Powered Interview Feedback System
How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.
Migrating from Pages to App Router
A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.