Back to Blog
Mar 3, 20264 min readOnuzulike Anthony

Sharing Code Between Next.js and Expo React Native

Monorepo setup, shared packages, universal components, platform-specific code, and keeping API clients in sync.

MobileReact NativeExpoMonorepo

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:

json
{
  "workspaces": ["apps/*", "packages/*"]
}

Each shared package is a simple TypeScript package with an index.ts entry:

json
// 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:

ts
// 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:

ts
// 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

ts
// 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:

ts
// 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:

js
// 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 window or document in shared code — guard with typeof 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.json paths and Metro's resolver.alias separately; 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.