Building a Scalable TypeScript Monorepo with pnpm Workspaces
Managing separate repositories for web and mobile applications often leads to code duplication, version conflicts, and slow iteration cycles. At Storyie, we solved these challenges by adopting a monorepo architecture powered by pnpm workspaces. In this post, we'll share how we structure our codebase, manage dependencies, and share code between Next.js (web) and Expo (mobile) while maintaining type safety and developer productivity.
Why We Chose a Monorepo
Before consolidating into a monorepo, we maintained separate repositories for our Next.js web application and Expo mobile app. This approach quickly became problematic:
- Code Duplication: Our Lexical rich text editor, database models, and UI components were duplicated across both repos
- Version Drift: Keeping dependencies in sync required manual coordination
- Slow Iteration: Making cross-platform changes required multiple PRs and careful timing
- Type Safety: Sharing TypeScript types between repos was cumbersome and error-prone
A monorepo solved all these issues by allowing us to:
- Share code across platforms with a single source of truth
- Use TypeScript project references for type safety across packages
- Run unified linting, testing, and formatting with Biome
- Deploy changes atomically (one PR updates both web and mobile)
Workspace Structure Overview
Our monorepo is organized using pnpm workspaces, which provide efficient dependency management and fast installs. Here's our pnpm-workspace.yaml configuration:
packages:
- 'apps/*'
- 'packages/*'This simple configuration tells pnpm to treat all directories under apps/ and packages/ as workspace packages. Here's our complete directory structure:
storyie/
├── apps/
│ ├── web/ # Next.js 16 app (@storyie/web)
│ └── expo/ # Expo SDK 53 app (@storyie/expo)
├── packages/
│ ├── ui/ # Shared UI components
│ ├── database/ # Drizzle ORM schemas
│ ├── lexical-common/ # Platform-agnostic Lexical core
│ ├── lexical-editor/ # Web-specific Lexical editor
│ └── lexical-editor-expo/ # Expo-specific Lexical editor
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.jsonThe key benefit of this structure is workspace hoisting: all packages share a single node_modules/ directory at the root, eliminating redundant installs and ensuring consistent dependency versions.
Package Organization Strategy
We organize our packages into two categories: applications and libraries.
Applications (apps/)
These are deployable applications that users interact with:
@storyie/web: Next.js 16 web application with App Router, Server Components, and SST deployment@storyie/expo: Expo SDK 53 mobile app with Expo Router v5, Firebase integration, and EAS builds
Libraries (packages/)
These are reusable packages consumed by applications:
@storyie/ui: Shared UI components (buttons, spinners, alerts)@storyie/database: Drizzle ORM schemas, migrations, and type-safe queries@storyie/lexical-common: Platform-agnostic Lexical editor core (no platform directives!)@storyie/lexical-editor: Web-specific Lexical editor with React DOM integration@storyie/lexical-editor-expo: Expo-specific Lexical editor using React Native WebView
Platform-Agnostic Packages: A Critical Pattern
The most important architectural decision we made was keeping lexical-common completely platform-agnostic. This package contains core Lexical nodes, themes, and configurations that work identically on both web and mobile.
Here's an example of our ImageNode from packages/lexical-common/src/nodes/ImageNode.ts:
// No "use client" or platform imports!
import { DecoratorNode } from 'lexical';
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__alt: string;
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(node.__src, node.__alt, node.__key);
}
exportJSON(): SerializedImageNode {
return {
src: this.__src,
alt: this.__alt,
type: 'image',
version: 1,
};
}
}This node works on both web (React DOM) and mobile (React Native WebView) because it has no platform-specific dependencies. The web and mobile packages then import and use this shared node:
// packages/lexical-editor/src/LexicalEditor.tsx (Web)
'use client';
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
const editorConfig = {
nodes: [ImageNode],
// ... other config
};// packages/lexical-editor-expo/src/LexicalEditorExpo.tsx (Mobile)
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
const editor = createEditor({
nodes: [ImageNode],
// ... other config
});Dependency Management with pnpm
pnpm provides several advantages over npm and yarn for monorepo development:
Workspace Protocol
The workspace:* protocol ensures that packages always use local versions of dependencies. Here's how we declare dependencies in apps/web/package.json:
{
"name": "@storyie/web",
"dependencies": {
"@storyie/ui": "workspace:*",
"@storyie/lexical-editor": "workspace:*",
"@storyie/database": "workspace:*",
"next": "^16.0.1",
"react": "^19.0.0"
}
}When we run pnpm install, pnpm creates symlinks from apps/web/node_modules/@storyie/ui to packages/ui, ensuring we always use the latest local code.
Workspace Commands
Our root package.json defines workspace-wide commands using pnpm's --filter flag:
{
"name": "@storyie/root",
"scripts": {
"dev": "pnpm --filter './apps/*' dev",
"dev:web": "pnpm --filter @storyie/web dev",
"dev:expo": "pnpm --filter @storyie/expo start",
"build:packages": "pnpm --filter './packages/*' build",
"build:web": "pnpm build:packages && pnpm --filter @storyie/web build",
"type-check": "pnpm -r type-check",
"lint": "biome check .",
"format": "biome check --write ."
}
}These commands enable powerful workflows:
# Build specific package
pnpm --filter @storyie/database build
# Run dev servers for all apps
pnpm --filter './apps/*' dev
# Recursive type checking across all packages
pnpm -r type-checkpnpm vs npm vs yarn
Here's why we chose pnpm for our monorepo:
| Feature | pnpm | npm | yarn |
| -------------------- | --------------------------------------- | ------------------------------ | ------- |
| Disk space | Efficient (content-addressable storage) | High (duplicates dependencies) | Medium |
| Install speed | Fast (parallel installs) | Slow | Medium |
| Workspace support | Excellent (workspace:* protocol) | Basic | Good |
| Phantom dependencies | Prevented (strict mode) | Allowed | Allowed |
pnpm's content-addressable storage means dependencies are stored once globally and symlinked into projects, saving gigabytes of disk space across multiple projects.
TypeScript Configuration & Project References
TypeScript project references enable fast incremental builds and cross-package type checking. Our root tsconfig.json references all workspace packages:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"strict": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler"
},
"references": [
{ "path": "./apps/web" },
{ "path": "./apps/expo" },
{ "path": "./packages/ui" },
{ "path": "./packages/database" },
{ "path": "./packages/lexical-common" },
{ "path": "./packages/lexical-editor" },
{ "path": "./packages/lexical-editor-expo" }
]
}Each package has its own tsconfig.json that extends the root configuration:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"composite": true
},
"include": ["src/**/*"],
"references": [{ "path": "../lexical-common" }]
}This setup provides several benefits:
- Incremental Builds: TypeScript only recompiles changed files
- Declaration Maps: Go-to-Definition works across packages in VS Code
- Type Safety: Importing from
@storyie/lexical-commonprovides full autocomplete
Here's what cross-package imports look like in practice:
// apps/web/lib/editor/config.ts
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
import { DefaultTheme } from '@storyie/lexical-common/themes/DefaultTheme';
// TypeScript autocomplete works perfectly!
const nodes = [ImageNode];
const theme = DefaultTheme;Build Orchestration & Scripts
Building a monorepo requires careful orchestration to ensure packages build before their dependents. We use a combination of sequential and parallel builds:
Sequential Builds
Packages must build before applications that depend on them:
# Build shared packages first, then web app
pnpm build:packages && pnpm --filter @storyie/web buildThis ensures @storyie/web can import compiled TypeScript declarations from @storyie/ui, @storyie/lexical-editor, and @storyie/database.
Parallel Builds
All packages can build in parallel since they don't depend on each other's build outputs:
# All packages build simultaneously
pnpm --filter './packages/*' buildEach package has a simple build script in its package.json:
{
"scripts": {
"build": "tsc"
}
}Build Dependency Matrix
Here's how our build dependencies flow:
| Package | Depends On |
| ------------------- | --------------------------------- |
| lexical-common | None (base package) |
| lexical-editor | lexical-common |
| lexical-editor-expo | lexical-common |
| ui | None |
| database | None |
| web | ui, database, lexical-editor |
| expo | ui, database, lexical-editor-expo |
This dependency graph ensures we build in the correct order: base packages → platform-specific packages → applications.
Cross-Platform Code Sharing
The core value of our monorepo is sharing code between Next.js and Expo without compromises. We achieve this through:
1. Platform-Agnostic Core
packages/lexical-common contains all shared Lexical editor logic with zero platform-specific code. No "use client" directives, no next/* imports, no expo-* dependencies.
2. Platform-Specific Wrappers
packages/lexical-editor (web) and packages/lexical-editor-expo (mobile) wrap the platform-agnostic core with platform-specific implementations.
Web wrapper:
// packages/lexical-editor/src/LexicalEditor.tsx
'use client';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
import { DefaultTheme } from '@storyie/lexical-common/themes/DefaultTheme';
export function LexicalEditor({ initialContent }) {
const editorConfig = {
namespace: 'storyie-web',
theme: DefaultTheme,
nodes: [ImageNode],
onError: (error) => console.error(error),
};
return (
<LexicalComposer initialConfig={editorConfig}>
{/* Web-specific plugins and toolbar */}
</LexicalComposer>
);
}Mobile wrapper (using React Native WebView):
// packages/lexical-editor-expo/src/LexicalEditorExpo.tsx
import { WebView } from 'react-native-webview';
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
export function LexicalEditorExpo({ initialContent, onContentChange }) {
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<script src="lexical.min.js"></script>
</head>
<body>
<div id="editor"></div>
<script>
const editor = createEditor({
nodes: [ImageNode],
// ... editor initialization
});
</script>
</body>
</html>
`;
return (
<WebView
source={{ html: htmlContent }}
onMessage={(event) => onContentChange(event.nativeEvent.data)}
/>
);
}3. JSON Serialization
Lexical's EditorState is serialized as JSON, which works perfectly across platforms:
{
"root": {
"children": [
{
"type": "paragraph",
"children": [{ "type": "text", "text": "Hello world", "format": 0 }]
},
{
"type": "image",
"src": "https://example.com/image.jpg",
"alt": "Example image"
}
]
}
}This JSON is stored in our Supabase PostgreSQL database as JSONB and works identically on web and mobile.
Development Workflow
Day-to-day development in the monorepo is streamlined with pnpm workspaces:
Running Dev Servers
A single command starts all apps in watch mode:
pnpm devThis runs both Next.js (port 3000) and Expo dev server simultaneously. Changes to packages automatically trigger hot reloads in both apps.
Making Cross-Platform Changes
Here's a typical workflow for adding a feature to the Lexical editor:
# 1. Edit shared package
vim packages/lexical-common/src/nodes/ImageNode.ts
# 2. Both apps auto-reload
# ✅ apps/web detects change
# ✅ apps/expo detects change
# 3. Verify types
pnpm type-check
# 4. Lint and format
pnpm lint
pnpm format
# 5. Commit once
git add .
git commit -m "feat: add alt text to ImageNode"A single PR updates both web and mobile - no coordination across repos required.
Unified Tooling
We use Biome for linting and formatting across the entire monorepo:
# Check all files
pnpm lint
# Auto-fix issues
pnpm formatTypeScript type checking runs recursively across all packages:
# Type check everything
pnpm type-check
# Or watch mode
pnpm type-check --watchConclusion & Key Takeaways
Adopting a pnpm monorepo transformed how we build Storyie. Here are the key lessons we learned:
- Code Reuse: Shared packages eliminate duplication and ensure consistency
- Type Safety: TypeScript project references provide cross-package autocomplete and Go-to-Definition
- Unified Tooling: Single lint/test/build configuration reduces cognitive overhead
- Fast Iteration: Change shared code once, updates reflect everywhere immediately
- Developer Experience: One repo, one PR, one merge simplifies collaboration
If you're building cross-platform applications, we highly recommend the monorepo approach with pnpm workspaces. The upfront investment in project structure pays dividends in developer productivity and code quality.
What's Next
Want to dive deeper into our architecture? Check out these related posts:
- Next.js 16 Deployment: Learn how we deploy our web app with SST and App Router
- Expo React Native: Explore our mobile architecture with Expo Router and Firebase
- Cross-Platform Lexical Editor: Deep dive into our platform-agnostic editor design
- Database Schema Design: See how we use Drizzle ORM for type-safe queries
---
Have questions or feedback? Share your thoughts on Twitter @StoryieApp or reach out to our engineering team.