Sharing Code Between Next.js and Expo React Native
Monorepo setup, shared packages, universal components, platform-specific code, and keeping API clients in sync.
NodWatch runs as both a Next.js web app and an Expo React Native app. Most of the business logic — API calls, type definitions, validation schemas, recommendation algorithms — is identical. The challenge is sharing that code without fighting the platform differences.
Monorepo Structure
The cleanest setup I've found uses npm workspaces (or pnpm workspaces) with a packages/ directory for shared code:
nodwatch/
apps/
web/ ← Next.js
mobile/ ← Expo
packages/
api/ ← shared API client (wraps fetch)
types/ ← shared TypeScript types
validators/ ← shared Zod schemas
package.json at the root:
{
"workspaces": ["apps/*", "packages/*"]
}Each shared package is a simple TypeScript package with an index.ts entry:
// packages/types/package.json
{
"name": "@nodwatch/types",
"main": "./index.ts",
"types": "./index.ts"
}Shared API Client
The API client lives in packages/api and calls the same backend from both platforms:
// packages/api/src/watchlist.ts
import type { WatchlistItem } from "@nodwatch/types";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? process.env.EXPO_PUBLIC_API_URL ?? "";
async function request<T>(path: string, options?: RequestInit, token?: string): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
export const watchlistApi = {
getAll: (token: string) => request<WatchlistItem[]>("/api/watchlist", {}, token),
add: (tmdbId: number, token: string) =>
request<void>("/api/watchlist", { method: "POST", body: JSON.stringify({ tmdbId }) }, token),
remove: (tmdbId: number, token: string) =>
request<void>(`/api/watchlist/${tmdbId}`, { method: "DELETE" }, token),
};Both apps import @nodwatch/api — no duplication.
Platform-Specific Code
React Native and Next.js diverge on navigation, storage, and UI primitives. Use .native.ts and .web.ts extensions for platform branches:
// packages/api/src/storage.web.ts
export async function saveToken(token: string) {
localStorage.setItem("session_token", token);
}
export async function getToken() {
return localStorage.getItem("session_token");
}
// packages/api/src/storage.native.ts
import * as SecureStore from "expo-secure-store";
export async function saveToken(token: string) {
await SecureStore.setItemAsync("session_token", token);
}
export async function getToken() {
return SecureStore.getItemAsync("session_token");
}Import ./storage from both platforms — Metro and Next.js each resolve the appropriate file.
Shared Zod Validators
// packages/validators/src/auth.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export type LoginInput = z.infer<typeof loginSchema>;Validate on the server (in Next.js API routes / server actions) and on the client (both web and mobile form submissions) from the same schema.
Auth Token Sharing
The web uses cookie-based sessions. Mobile uses bearer tokens. In Better-Auth, session.token is always present — on mobile, store it and include it in every request:
// apps/mobile/src/hooks/use-auth.ts
import { authClient } from "@nodwatch/api/auth-client";
import { saveToken } from "@nodwatch/api/storage";
export function useLogin() {
return async (email: string, password: string) => {
const result = await authClient.signIn.email({ email, password });
if (result.data?.session.token) {
await saveToken(result.data.session.token);
}
return result;
};
}Metro Config for Monorepo
Expo's Metro bundler needs explicit configuration to resolve workspace packages:
// apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const root = path.resolve(__dirname, "../..");
const config = getDefaultConfig(__dirname, { isCSSEnabled: true });
config.watchFolders = [root];
config.resolver.nodeModulesPaths = [
path.resolve(__dirname, "node_modules"),
path.resolve(root, "node_modules"),
];
module.exports = config;Gotchas
- No
windowordocumentin shared code — guard withtypeof window !== "undefined"or use platform branches - Expo SDK versions — some Expo modules only work with specific React Native versions; pin them carefully
- Fast Refresh — Metro doesn't always pick up changes in workspace packages; restart the bundler after adding new exports
- Path aliases — configure
tsconfig.jsonpathsand Metro'sresolver.aliasseparately; they don't share config
The payoff is real: one API client, one type system, one validation layer. Feature parity between web and mobile stops being an afterthought.