Home / Articles

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.

Publisher: REACTAPP.TOP5 min read1135 words

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

  • api is the NestJS backend.
  • web is the Next.js application.
  • mobile is 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.