原文地址:https://medium.com/@beenakumawat002/turborepo-monorepo-in-2025-next-js-react-native-shared-ui-type-safe-api-%EF%B8%8F-6194c83adff9
原文作者: Elizabeth ✨
最终你将得到什么
- 一个包含 Next.js(web)、React Native(mobile)以及共享 packages 的仓库
- 使用 pnpm workspaces + Turborepo,实现快速构建与缓存
- 跨 apps / packages 的 TypeScript project references
- 共享 UI(React primitives + RN bindings)以及共享 API client(Zod + OpenAPI / tRPC-ready)
- 全仓库统一使用的 ESLint / Prettier / 类 Ruff 的单一配置
- 解决 React Native monorepo 中 Metro + symlinks 的问题
- 使用 Changesets 进行版本管理与发布
- GitHub Actions CI(build、test、lint),可选集成 EAS 的 mobile hook
0)整体鸟瞰 🗺️
acme/
apps/
web/ # Next.js 15(App Router)
mobile/ # React Native(Expo 或 CLI)
packages/
ui/ # 共享 UI 组件
api/ # 类型安全的 API client(fetch + zod)
config/ # eslint、tsconfig、prettier
types/ # 全局类型与 schema contracts
.github/workflows/ci.yml
turbo.json
package.json
pnpm-workspace.yaml
tsconfig.base.json
1)初始化 workspace ⚙️
# prereqs: Node 20+, pnpm 9+, git
mkdir acme && cd acme
pnpm init -y
pnpm dlx turbo@latest init
pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
package.json
{
"name": "acme",
"private": true,
"packageManager": "pnpm@9.6.0",
"scripts": {
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean",
"release": "changeset version && pnpm -r build && changeset publish"
},
"devDependencies": {
"turbo": "^2.0.0",
"changesets": "^2.26.2",
"typescript": "^5.5.4"
}
}
tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@acme/ui/*": ["packages/ui/src/*"],
"@acme/api/*": ["packages/api/src/*"],
"@acme/types/*": ["packages/types/src/*"],
"@acme/config/*": ["packages/config/src/*"]
}
}
}
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"outputs": ["dist/**", ".next/**", "build/**"],
"dependsOn": ["^build"]
},
"lint": { "outputs": [] },
"test": { "outputs": [] },
"clean": { "cache": false }
}
}
2)共享配置包(ESLint / Prettier / TS)🧰
pnpm -w add -D eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin
mkdir -p packages/config/src
packages/config/package.json
{
"name": "@acme/config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.js"
}
packages/config/src/eslint.cjs
module.exports = {
root: false,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react", "react-hooks"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
settings: { react: { version: "detect" } },
rules: {
"@typescript-eslint/consistent-type-imports": "warn",
"react/react-in-jsx-scope": "off"
}
};
packages/config/src/index.js
export { default as eslint } from "./eslint.cjs";
3)类型与契约(Zod)🧾
mkdir -p packages/types/src
pnpm -w add zod
packages/types/package.json
{
"name": "@acme/types",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"exports": "./src/index.ts",
"devDependencies": { "typescript": "^5.5.4" }
}
packages/types/src/index.ts
import { z } from "zod";
export const UserId = z.string().uuid().brand<"UserId">();
export type UserId = z.infer<typeof UserId>;
export const User = z.object({
id: UserId,
email: z.string().email(),
name: z.string().min(1),
});
export type User = z.infer<typeof User>;
export const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export type CreateUser = z.infer<typeof CreateUser>;
4)类型安全的 API client(fetch + Zod)🌐✅
mkdir -p packages/api/src
packages/api/package.json
{
"name": "@acme/api",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"exports": "./src/index.ts",
"dependencies": { "zod": "^3.23.8", "@acme/types": "workspace:*" }
}
packages/api/src/index.ts
import { z } from "zod";
import { User, CreateUser } from "@acme/types";
const ApiError = z.object({ message: z.string() });
type ApiError = z.infer<typeof ApiError>;
async function parse<T>(res: Response, schema: z.ZodType<T>): Promise<T> {
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = ApiError.safeParse(json);
throw new Error(err.success ? err.data.message : `HTTP ${res.status}`);
}
const parsed = schema.safeParse(json);
if (!parsed.success) throw new Error(parsed.error.message);
return parsed.data;
}
export class AcmeClient {
constructor(private base = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000") {}
listUsers() {
return fetch(`${this.base}/api/users`, { cache: "no-store" }).then((r) =>
parse(r, z.array(User))
);
}
createUser(input: CreateUser) {
return fetch(`${this.base}/api/users`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
}).then((r) => parse(r, User));
}
}
5)共享 UI 包(web + native)🎨
我们保持 primitives 的兼容性:web 使用 div ,mobile 使用 react-native 。对外暴露统一 API,并提供平台特定的入口。
mkdir -p packages/ui/src
pnpm -w add react react-native
packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.0.0",
"type": "module",
"main": "src/index.web.tsx",
"react-native": "src/index.native.tsx",
"exports": {
".": {
"import": {
"react-native": "./src/index.native.tsx",
"default": "./src/index.web.tsx"
}
}
},
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.73.0 || >=0.80.0"
}
}
packages/ui/src/Button.tsx
// platform-agnostic props
export type ButtonProps = { title: string; onPress?: () => void; className?: string };
packages/ui/src/index.web.tsx
import React from "react";
import type { ButtonProps } from "./Button";
export const Button: React.FC<ButtonProps> = ({ title, onPress, className }) => (
<button className={className ?? "px-3 py-2 rounded bg-black text-white"} onClick={onPress}>
{title}
</button>
);
packages/ui/src/index.native.tsx
import React from "react";
import { Pressable, Text, StyleSheet } from "react-native";
import type { ButtonProps } from "./Button";
export const Button: React.FC<ButtonProps> = ({ title, onPress }) => (
<Pressable onPress={onPress} style={styles.btn}>
<Text style={styles.txt}>{title}</Text>
</Pressable>
);
const styles = StyleSheet.create({
btn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8, backgroundColor: "#000" },
txt: { color: "#fff", fontWeight: "600" }
});
6)Next.js 应用(web)🌐
pnpm dlx create-next-app@latest apps/web --ts --eslint --tailwind --app
apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"paths": {
"@acme/ui/*": ["../../packages/ui/src/*"],
"@acme/api/*": ["../../packages/api/src/*"],
"@acme/types/*": ["../../packages/types/src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
使用共享 UI 与 API
apps/web/app/page.tsx
import { Button } from "@acme/ui";
import { AcmeClient } from "@acme/api";
import Link from "next/link";
export default async function Page() {
const api = new AcmeClient();
const users = await api.listUsers();
return (
<main className="p-8 space-y-4">
<h1 className="text-2xl font-bold">Web Dashboard 🌐</h1>
<ul className="list-disc pl-6">
{users.map(u => <li key={u.id}>{u.email} — {u.name}</li>)}
</ul>
<Link href="/new">
<Button title="Create user" />
</Link>
</main>
);
}
简单的 route handler
apps/web/app/api/users/route.ts
import { NextResponse } from "next/server";
import { User, CreateUser } from "@acme/types";
import { z } from "zod";
const db: z.infer<typeof User>[] = [];
export async function GET() {
return NextResponse.json(db, { headers: { "cache-control": "no-store" } });
}
export async function POST(req: Request) {
const body = await req.json();
const parsed = CreateUser.safeParse(body);
if (!parsed.success) return new NextResponse("Invalid", { status: 400 });
const newUser: z.infer<typeof User> = {
id: crypto.randomUUID() as any,
email: parsed.data.email,
name: parsed.data.name
};
db.push(newUser);
return NextResponse.json(newUser);
}
7)React Native 应用(Expo)📱
Expo 能显著简化 Monorepo 的配置。
pnpm dlx create-expo-app apps/mobile -t
Monorepo 相关修复
apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
// 1) 监听 monorepo 根目录,让 Metro 能识别 packages/*
config.watchFolders = [workspaceRoot];
// 2) 将 symlink 的 packages 解析到源码
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules")
];
// 3) 允许从 packages 中加载 TS / TSX
config.resolver.sourceExts.push("cjs");
module.exports = config;
apps/mobile/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
"@acme/ui/*": ["../../packages/ui/src/*"],
"@acme/api/*": ["../../packages/api/src/*"],
"@acme/types/*": ["../../packages/types/src/*"]
}
}
}
使用共享 UI 与 API
apps/mobile/App.tsx
import React, { useEffect, useState } from "react";
import { SafeAreaView, Text, View } from "react-native";
import { Button } from "@acme/ui";
import { AcmeClient } from "@acme/api";
export default function App() {
const api = new AcmeClient("http://10.0.2.2:3000"); // Android emulator → host computer
const [users, setUsers] = useState<{id:string;email:string;name:string}[]>([]);
useEffect(() => {
api.listUsers().then(setUsers).catch(console.error);
}, []);
return (
<SafeAreaView>
<View style={{padding:16}}>
<Text style={{fontSize:20, fontWeight: "700", marginBottom:12}}>
Mobile Dashboard 📱
</Text>
{users.map(u => (
<Text key={u.id}>{u.email} — {u.name}</Text>
))}
<Button title="Refresh" onPress={() => api.listUsers().then(setUsers)} />
</View>
</SafeAreaView>
);
}
提示:iOS simulator 使用 http://localhost:3000 ;Android emulator 使用 http://10.0.2.2:3000 ;真机请使用你电脑的局域网 IP。
8)全仓库统一 lint / test / scripts ✅
为各 package 添加 scripts:
apps/web/package.json
{
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"test": "vitest",
"clean": "rm -rf .next"
},
"eslintConfig": { "extends": ["@acme/config/src/eslint.cjs"] }
}
apps/mobile/package.json
{
"scripts": {
"dev": "expo start -c",
"build": "echo 'Build via EAS or native tooling'",
"lint": "eslint .",
"test": "jest",
"clean": "rm -rf .expo"
},
"eslintConfig": { "extends": ["@acme/config/src/eslint.cjs"] }
}
packages/ui/package.json
{ "scripts": { "build": "tsc -p tsconfig.json -b", "lint": "eslint .", "test": "vitest", "clean": "rimraf dist" } }
packages/api/package.json
{ "scripts": { "build": "tsc -p tsconfig.json -b", "lint": "eslint .", "test": "vitest", "clean": "rimraf dist" } }
9)使用 Changesets 进行版本管理与发布 📦
pnpm dlx changeset init
这会创建 .changeset/ 。当你修改了某个 package:
pnpm changeset
# 选择 packages、升级类型、填写说明
pnpm release
你可以将内部包发布到私有 registry(GitHub Packages / Nexus),或者仅作为 workspace 内部使用。
10)GitHub Actions CI 🧪🛠️
.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm -r test
- run: pnpm build
移动端 CI/CD 可单独添加 EAS workflow 或 Fastlane。
11)开发工作流 🧑💻
# 在仓库根目录
pnpm dev
# → Next.js 运行在 3000,Expo dev server 同时启动
得益于 Metro 配置和 pnpm workspaces,packages/ui 或 packages/api 中的任何改动都会实时同步到两个应用中。
12)常见坑位(及解决方案)🧯
- Metro 找不到共享 packages → 确认已配置
watchFolders 与 nodeModulesPaths
- react 被重复安装 → 在根目录统一版本,并在
@acme/ui 中声明为 peerDependencies
- Path aliases 不生效 → 检查
tsconfig.base.json 以及各 app 的 tsconfig.json
- Android 网络问题 → emulator 使用
10.0.2.2 ,真机使用电脑 IP
- Next.js + ESM → 保持
"type": "module" ,并在 TS 中启用 verbatimModuleSyntax
13)生产环境检查清单 ✅
- 通过
@acme/config 统一 eslint / prettier / tsconfig
- 使用
@acme/types(Zod)集中管理类型契约
- API client 同时校验 runtime 并推断 compile time 类型
- UI 包同时导出 web / native 入口
- Metro 已正确配置 symlink packages
- Turbo pipeline 启用缓存,并在 CI 中恢复 cache
- 环境变量策略(dev 使用 dotenv,prod 使用平台 secrets)
- Changesets 管理版本与发布说明
- E2E 冒烟测试(Web 使用 Playwright,Mobile 使用 Detox)——可选但强烈推荐
对于构建跨平台的现代化应用,一个高效的 React Native Monorepo 架构至关重要。本文分享的实践方案,希望能帮助你在管理复杂项目时理清思路。如果你对这类开发实践感兴趣,欢迎持续关注 云栈社区 ,获取更多工程化与架构方面的深度解析。