Sharing Code Between NestJS, Next.js and Expo (React Native) in a pnpm Turborepo
Lay out a pnpm Turborepo monorepo for a NestJS API, a Next.js site and an Expo React Native app, and decide which types and schemas go in shared packages.
This article is published in English.
Sources: Building a Modern Full-Stack Monorepo with Turborepo, NestJS, Next.js, and React Native
A product with a NestJS backend, a Next.js web app and an Expo (React Native) mobile app usually starts life as three repositories, and the cost shows up fast. A changed type gets copied by hand, validation rules exist in three versions, and each app's API client quietly drifts away from the others. A Turborepo monorepo on pnpm workspaces keeps the three apps deployable on their own while giving them one home for the code they should agree on. Below is a workspace layout for this stack and a rule for deciding what belongs in shared packages; whether Turborepo is worth adding at all is covered in where Turborepo fits in a NestJS monorepo.
From three repositories to one workspace
The starting point is usually three sibling projects with nothing tying them together:
backend/
frontend/
mobile/
In a monorepo they become workspaces in a single repository, orchestrated by Turborepo and linked by pnpm:
my-project/
├── apps/
├── packages/
├── turbo.json
└── pnpm-workspace.yaml
Every app now shares one dependency install, one set of TypeScript and lint configs, and one place for reusable code. On a small project that can feel like ceremony. On a medium or large one, removing the copy-and-sync work is one of the biggest productivity wins available.
A layout with one job per folder
A structure that scales well for this stack:
my-project/
apps/
├── api/
├── web/
└── mobile/
packages/
├── api-client/
├── config/
├── types/
├── utils/
├── validation/
└── ui/
turbo.json
package.json
pnpm-workspace.yaml
The top level separates runnable things from reusable things, and each folder has a single responsibility.
apps: independently deployable programs
apiis the NestJS backend.webis the Next.js application.mobileis the Expo (React Native) app.
Each keeps its own build, config and deployment pipeline; sharing a repository does not mean shipping together.
packages: code the apps agree on
This is where the monorepo pays off. Shared logic is written once here and imported wherever it is needed. A simple case is a domain type:
//packages/types
export interface User {
id: string;
name: string;
email: string;
}
Give the package a name such as @repo/types in its package.json, add it as a workspace:* dependency in each app, and every app imports the same definition:
import { User } from "@repo/types";
There are no parallel copies of the model and no interfaces quietly diverging. When User gains a field, the type checker points at every place in every app that needs to handle it.
One validation schema for server and clients
Validation is the other classic source of duplication. The same registration rules tend to appear as a NestJS DTO, a Next.js form validator and a React Native form validator, and sooner or later one of them falls out of step.
A shared Zod schema removes the problem:
import { z } from "zod";
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
The API validates incoming requests against RegisterSchema, and both clients use it for form validation, so they all enforce identical rules. If the minimum password length changes, one file changes. You can also derive the TypeScript type with z.infer<typeof RegisterSchema> instead of declaring it separately. Note that Zod 4 introduced top-level format helpers such as z.email(); the chained z.string().email() form still works but is marked deprecated there, so match the style to your Zod version.
A single API client package
Instead of each app creating its own Axios instance, put one in a dedicated package:
//packages/api-client
import axios from "axios";
export const api = axios.create({
baseURL: process.env.API_URL,
});
Every client app imports it from the same place:
import { api } from "@repo/api-client";
Auth headers, interceptors, retry and error handling are then defined once and behave the same on web and mobile. One caveat: process.env.API_URL is read differently by each framework. Next.js only exposes variables prefixed with NEXT_PUBLIC_ to browser code, and Expo inlines only EXPO_PUBLIC_ variables into the app bundle. In practice it is cleaner to export a factory that takes the base URL as an argument and let each app pass its own configured value.
Why UI components rarely transfer
Teams often ask whether React components can be shared between Next.js and React Native. Not directly. The web app renders DOM elements such as:
<div>
while React Native renders native primitives such as:
<View>
Both use React, but the component vocabularies differ, so a component written for one platform does not render on the other. If cross-platform UI is a real goal, libraries such as Tamagui, Solito or React Native Web are built for that and worth evaluating. Otherwise, share business logic, validation, hooks, types and utilities, and keep each platform's UI in its own app.
Running the workspace
Turborepo keeps day-to-day commands simple. With a root dev script that calls turbo dev, one command starts every app:
pnpm dev
To work on one app, use pnpm's filter with the target package's name:
pnpm --filter api dev
pnpm --filter web dev
pnpm --filter mobile start
Behind those commands, Turborepo reads the dependency graph, runs tasks in the right order and caches results, so shared packages build before the apps that depend on them and unchanged work is skipped.
A rule of thumb for what belongs in packages
Share logic; do not force shared UI. These travel well between apps:
- TypeScript types and enums
- Zod schemas
- Utility functions and constants
- The API client and authentication helpers
- Business logic
- Shared configuration (TypeScript, ESLint, formatting)
- Internal libraries
UI components belong in a shared package only when the project was deliberately built on a cross-platform UI solution. Also keep server-only code, such as database access or secrets handling, out of packages that client apps import, or it may end up in a browser or mobile bundle.
Why the boundaries are the point
The value of this layout comes from its boundaries. Apps stay independent enough to evolve and deploy on their own schedules, while the contract between them, types, schemas and the API client, lives in exactly one place. Changes propagate through the type checker instead of copy-paste, and the workspace stays manageable as the codebase grows.