Engineering

TypeScript Monorepo Setup: Sharing Types Between Workers and Next.js Apps

We built a pnpm & Turborepo monorepo that shares raw TypeScript sources across Cloudflare Workers and Next.js apps, achieving full type safety and zero production mismatches across runtimes.

monorepo-blog-post

Building a unified API platform that spans Cloudflare Workers and Next.js applications presents a unique challenge: how do you maintain type safety across completely different JavaScript runtimes? At Outstand, we've developed a monorepo structure that allows us to share TypeScript types seamlessly between our edge Workers and server-rendered React apps—without sacrificing performance or developer experience.

Here's how we did it, and what we learned along the way.

The Challenge: One Codebase, Multiple Runtimes

When you're building a modern SaaS platform, you often need different types of applications working together:

  • Edge Workers (like Cloudflare Workers) for high-performance API endpoints that run close to users globally
  • Next.js applications for customer-facing dashboards, documentation sites, and marketing pages
  • Shared business logic that needs to work consistently across both environments

The problem? These runtimes have fundamentally different requirements:

Cloudflare Workers run on the V8 engine with specific constraints and optimizations for edge computing. They need lean, tree-shakeable code that can start up in milliseconds. Next.js applications, on the other hand, compile for Node.js environments and need React-specific configurations, server-side rendering support, and different module resolution strategies.

If you try to share code naively, you'll run into TypeScript configuration conflicts, module resolution errors, and frustrating type mismatches between environments.

Our Monorepo Philosophy

We run our entire platform as a pnpm monorepo with Turborepo for build orchestration. Our structure includes:

  • 5 Cloudflare Workers (so far!) handling API requests, proxying, background jobs, and webhook processing
  • 4 Next.js applications for our web dashboard, public documentation, landing page, and blog
  • 6 shared packages containing database schemas, authentication logic, business rules, UI components, and TypeScript configurations

The key insight: shared packages export raw TypeScript source files, not compiled JavaScript. This allows each consuming application to transpile code according to its own runtime requirements.

The Foundation: Shared TypeScript Configs

The cornerstone of our setup is a dedicated TypeScript configuration package. Instead of duplicating tsconfig.json files across 9+ applications, we maintain versioned, composable configurations that apps extend.

Base Configuration

Our base TypeScript config enforces strict typing rules and modern ECMAScript features across the entire monorepo. It includes:

  • Strict type checking (no implicit any, strict null checks, etc.)
  • Modern ES2022 target for optimal performance
  • Declaration maps for better IDE experience
  • Isolated modules to ensure each file can be compiled independently

This base configuration represents our "contract"—the minimum standards every package and app must meet.

Specialized Configurations

From this base, we extend into specialized configs:

Next.js Configuration: Adds React-specific settings like JSX preservation, Next.js compiler plugins, and bundler module resolution. It also sets noEmit: true since Next.js handles the build process.

Worker Configuration: Workers use a different module system optimized for V8, with stricter bundling requirements and no DOM types (since they don't run in browsers).

By maintaining these as a versioned package, we can update TypeScript settings across all 9 apps with a single dependency bump.

Package Architecture: Export Source, Not Builds

Here's where most teams get stuck: should shared packages export compiled JavaScript or raw TypeScript?

We export raw TypeScript source files. Here's why:

Different transpilation needs: Cloudflare Workers and Next.js use different bundlers (esbuild vs. webpack/turbopack) with different optimization strategies. By exporting source, each consumer can optimize for its runtime.

Better tree-shaking: When you import a specific function from a package, bundlers can trace dependencies through TypeScript and eliminate unused code. Pre-compiled JavaScript makes this harder.

Faster development: Changes to shared packages are immediately reflected in consuming apps without a build step. Your IDE picks up changes instantly.

Type safety: Exported TypeScript preserves full type information. Compiled JavaScript with .d.ts files can lose nuance and cause version drift.

Real-World Implementation

Let's walk through how this works in practice.

The Database Package

Our database package contains Drizzle ORM schemas that define every table, column, and relationship in our system. This package is used by:

  • Workers that handle API requests
  • Next.js server actions that mutate data
  • Background jobs that process queues
  • Admin dashboards that display analytics

The package exports TypeScript types for every table, inferred types for queries, and utility functions. When we add a new column to a table, TypeScript immediately flags every place in our codebase that needs updating—across Workers, Next.js apps, and background jobs.

The Engine Package

This contains our core business logic: authentication middleware, post creation functions, social media provider integrations, and API client code. It's consumed by both our API Workers and our Next.js dashboard.

For example, our post creation function accepts the same typed parameters whether it's called from:

  • A Cloudflare Worker handling a REST API request
  • A Next.js server action triggered by a form submission
  • A background Worker processing a scheduled task

The function signature, return types, and error handling are identical across all environments. If we change a parameter type, TypeScript catches mismatches in all three places during development.

The Auth Package

Authentication state, session types, and authorization logic are shared between Workers (which validate API keys) and Next.js apps (which handle OAuth flows and session management).

By centralizing these types, we ensure that a session token created by a Next.js OAuth callback can be validated by a Worker without any serialization mismatches or type casting.

Handling Runtime-Specific Code

Some code can't be shared because it's runtime-specific. We handle this through:

Dependency injection: Shared functions accept runtime-specific clients as parameters. For example, a function that queries the database accepts a database client—Next.js passes its connection pool, Workers pass their D1 client.

Environment-specific packages: Some packages are explicitly for one environment. Our UI component library is Next.js-only. Our Worker utilities package is Workers-only. These don't try to be universal.

Feature detection: When necessary, shared code uses feature detection (typeof window !== 'undefined') to branch behavior, though we minimize this.

Development Workflow

Here's how our day-to-day development works with this setup:

When building a new API endpoint, we:

  1. Define types in the engine package (request schema, response types, error codes)
  2. Implement the Worker endpoint using those types
  3. Build the Next.js UI that calls the endpoint, getting full type safety on the client
  4. Update our database schema if needed—types propagate automatically

TypeScript acts as our contract between frontend and backend. If the API response changes shape, the Next.js app won't compile until we update the UI code.

The Turborepo Layer

Turborepo orchestrates builds and ensures packages compile in dependency order. Our configuration defines:

  • Build tasks depend on dependencies being built first (^build dependency)
  • Outputs are cached so rebuilding one app doesn't rebuild unchanged dependencies
  • Development mode runs all apps in parallel with hot reloading

This means when we change a type in the database package, Turborepo knows to rebuild the engine package (which depends on database) and then the apps (which depend on engine). It does this efficiently, only rebuilding what changed.

Lessons Learned

1. Don't Pre-compile Shared Packages

Our biggest early mistake was compiling packages to JavaScript before publishing to the monorepo. This caused endless pain with module formats, source maps, and type definitions. Exporting raw TypeScript solved everything.

2. Version Your TypeScript Configs

Treating TypeScript configs as a versioned package gives you control over updates. When TypeScript 5.0 introduced new features, we updated our config package once and rolled it out to apps gradually.

3. Use Workspace Protocols

Using workspace:* in package.json dependencies (pnpm's workspace protocol) ensures you always get the local version of packages, never accidentally pulling from a registry.

4. Skip Library Type Checking

With 9 apps sharing dependencies, we ended up with duplicate type definitions. Setting skipLibCheck: true in all configs eliminated false positives without sacrificing our own code's type safety.

5. Watch Mode is Essential

In development, Turborepo's watch mode keeps all apps and packages in sync. Change a type in a shared package, and you immediately see type errors in consuming apps—no manual rebuilds.

Impact on Our Platform

Today, our monorepo handles:

  • 8.4M+ social media posts managed through Workers
  • 1,252+ social accounts authenticated via Next.js OAuth flows
  • 10+ social platform integrations with shared type-safe clients
  • 99.9% uptime with zero production type-related bugs in the last 6 months

The last point is key: since implementing this architecture, we haven't had a single production incident caused by type mismatches between our Worker APIs and Next.js frontends. Types catch issues at compile time that would have been runtime errors in production.

Performance Characteristics

This approach has zero runtime overhead. Each app bundles only what it imports, and because bundlers see the full TypeScript source, tree-shaking is extremely effective.

Our Workers average 10ms cold start times despite importing from shared packages. Next.js builds are fast because Turborepo caches unchanged packages.

Conclusion

Sharing TypeScript types between Cloudflare Workers and Next.js apps in a monorepo comes down to a few key principles:

Export source, not compiled code from shared packages Use composable TypeScript configs as versioned packages

Let each runtime handle its own transpilation needs

Leverage Turborepo for efficient build orchestration

Embrace pnpm workspaces for reliable dependency management

This architecture scales. We've grown from 3 apps to 9 without restructuring. When we add a new Worker or Next.js app, it takes minutes to configure because we extend existing patterns.

If you're building a multi-runtime TypeScript platform, investing in proper monorepo architecture upfront will save you months of refactoring later. The productivity gains from shared types, instant feedback, and elimination of API contract bugs are substantial.

Building a unified API platform comes with complex architectural decisions. If you're curious about our approach to social media API design, OAuth abstraction, or edge computing at scale, let us know—we love talking about developer infrastructure.