When to extract a shared package in a monorepo: lessons from running 10 packages

Storyie Engineering Team
8 min read

After nearly a year running a pnpm monorepo with both a Next.js web app and an Expo mobile app, we worked out a repeatable set of criteria for deciding when to extract shared code into a package — and when to leave it inside the app.

Storyie is a pnpm monorepo with two apps — a Next.js web app and an Expo mobile app — sharing a common packages/ directory. We currently have 10 packages in that directory:

packages/
├── bots          # AI diary generation
├── config        # shared constants and configuration
├── database      # Drizzle ORM schemas and queries
├── emails        # email templates and sending
├── jobs          # background jobs
├── lexical-common  # Lexical editor nodes and config (platform-agnostic)
├── lexical-editor  # Lexical editor UI wrapper (web)
├── subscription  # plan limits and entitlement logic
├── theme         # design tokens and color palette
└── x-posting     # automated X/Twitter posting

We did not start with 10 packages. They accumulated over nearly a year of development, each one the result of a deliberate choice — or an uncomfortable refactor after getting the choice wrong. This post is about the decision framework we settled on.

TL;DR

Three axes drive every extraction decision:

Axis

Extract

Keep in app

Consumers

Both web and Expo need it

Only one app uses it

Platform dependency

Pure TypeScript — no platform imports

Depends on next/*, expo-*, or react-native

Logic character

Input → output calculation

UI layout, navigation, or platform APIs

And a fourth point that is just as important: not extracting is a valid decision. Over-abstracting costs more than it saves.

The three decision axes

1. Does more than one app use it?

This is the clearest test. If both the web app and the mobile app need the same logic, put it in a package. If only one app needs it, strong reasons have to exist to justify a package — and they rarely do.

The subscription package passes this test immediately: the web server enforces plan limits in Server Actions, and the Expo client enforces them before showing UI. Both apps need PLAN_LIMITS. Keeping those constants in one place is not optional.

2. Can it be made platform-agnostic?

A package that imports next/headers or expo-secure-store cannot be shared. The platform agnosticism check comes second because even widely-used logic sometimes cannot be cleanly extracted — in which case duplicating it is the right answer.

The rule we apply to every package in packages/ (except database, which is server-only by design):

  • No "use client", "use server", or "use dom" directives
  • No imports from next/*
  • No imports from expo-* or react-native

3. What is the ratio of change frequency to blast radius?

Business rules that change often and affect both apps are the strongest case for a typed shared package. When the subscription package changes a limit value, TypeScript propagates the constraint to every consumer at compile time. A mismatched limit between web and mobile is exactly the kind of silent divergence that frustrates users and is hard to debug.

Pure calculations are easy to test in isolation too — a function that takes plan type and returns a limit needs no database, no HTTP client, no mock setup.

Packages that worked well

subscription — pure logic with no external dependencies

The cleanest example we have. PLAN_LIMITS and the functions that evaluate them are plain TypeScript with zero external imports.

// packages/subscription/src/constants/limits.ts
export const PLAN_LIMITS = {
  free: { monthlyEntries: 31, imagesPerEntry: 1 },
  pro: { monthlyEntries: Infinity, imagesPerEntry: 20 },
} as const;

No database calls, no HTTP, no side effects. Because it is pure, it runs identically in a Next.js Server Action, an Expo client component, or a Jest test. The package tests are straightforward to write and fast to run.

theme — visual consistency enforced at the type level

Design tokens — colors, spacing, font sizes — drift the moment they live in two places. theme exposes three entry points:

{
  "exports": {
    ".": "./dist/index.js",
    "./css": "./dist/css.js",
    "./lexical": "./dist/lexical.js"
  }
}

A color used in a React Native stylesheet and a CSS custom property both ultimately come from the same token definition. Updating one updates both. The alternative — keeping separate token files per app — guarantees divergence over time as each app's copy drifts independently.

lexical-common and lexical-editor — separating headless from UI

We split Lexical into two packages intentionally:

  • lexical-common: node definitions, serialization/deserialization, config factories, command tokens — everything that runs headlessly
  • lexical-editor: the React component layer wrapping those primitives for web

The reason is the bots package. It uses @lexical/headless to parse and generate diary content on the server. It needs lexicalCommonNodes and createBaseConfig — but importing lexical-editor from a server package would drag in DOM-bound code where it has no business being.

bots → lexical-common  ← lexical-editor ← apps/web, apps/expo
                ↑
         apps/web (SSR)

Mixing logic and UI in the same package is only painful when a server-side consumer needs just the logic. By the time that need becomes apparent, splitting the package is expensive. Separating them upfront costs almost nothing.

What we deliberately did not extract

Auth logic

Supabase Auth looks similar across platforms at the interface level — sign in, sign out, get session. The implementations are completely different: web uses cookie-based sessions via @supabase/ssr; mobile uses expo-secure-store. Every attempt to write a shared abstraction produced a thicker indirection layer that was harder to reason about than just writing the platform-specific code in each app. We accepted the mild duplication.

The signal: similar interface, completely different implementation → keep it in each app.

Page and screen components

The diary list page on web is an App Router Server Component. The diary list screen on mobile is an Expo Router screen with a FlatList. The structure is so different that sharing UI components adds complexity with almost no benefit. The data fetching logic that feeds them is different too — web uses Drizzle ORM in Server Actions; mobile uses the Supabase JS client directly.

The signal: platform-specific UI and navigation → keep it in each app.

API client / data fetching layer

The web app hits the database directly through Drizzle ORM. The mobile app goes through the Supabase JS client. The access patterns are fundamentally different, so a shared package would either abstract away things that matter or end up being a thin wrapper around nothing.

packages/database is the partial exception: it exists to separate schema and query definitions from the web app, not to share them with mobile. Mobile never imports from @storyie/database.

Enforcing platform agnosticism

The restriction "no platform-specific imports in shared packages" is easy to state and easy to violate by accident. We document it in CLAUDE.md and AGENTS.md so both human reviewers and AI agents working on the codebase encounter the rule immediately:

<!-- AGENTS.md -->
**CRITICAL**: lexical-common, subscription, theme, config must remain platform-agnostic:
- No "use client", "use server", "use dom"
- No platform-specific imports (next/*, expo-*)

TypeScript type checking catches some violations indirectly — if a shared package imports next/headers, the build fails when the package is used on mobile. But it is not a complete enforcement mechanism, because some platform-specific APIs are typed broadly enough to not trigger a type error. Code review is still necessary.

A decision flowchart for placement

When we are unsure where new logic belongs:

Used by both web and Expo?
├── No → put it in the app (apps/web/ or apps/expo/)
└── Yes
    ├── Can it be made platform-agnostic?
    │   ├── No → write it separately in each app
    │   └── Yes
    │       ├── Does it fit an existing package's responsibility?
    │       │   ├── Yes → add it to that package
    │       │   └── No → create a new package
    │       └── Does it mix logic and UI?
    │           ├── Yes → split into a logic package and a UI package
    │           └── No → one package is fine

The real costs of 10 packages

We try to be honest with ourselves about what maintaining this many packages actually costs.

Build chaining. When a shared package changes, the dev servers do not pick up the update automatically. We run pnpm build:packages and restart. This is the most routine friction in day-to-day development. We tried resolving tsconfig paths directly to source files to avoid the rebuild step, but the behavior differences between Expo and Next.js were annoying enough that we went back to referencing built output.

Versioning — or rather, the decision to skip it. Every internal package uses workspace:*. There is no semantic versioning, no changelogs, no release process. For a monorepo where both apps deploy together, versioning internal packages adds overhead with no benefit. "The develop branch always builds" is the correctness contract we care about.

Placement decisions. As package count grows, "where does this go?" becomes a harder question. Our tiebreaker is dependency direction. If placing logic in package A requires A to import from B in a way that feels backwards — for example, subscription reaching into database — the logic probably belongs somewhere else or in a new package with a cleaner dependency graph.

Summary

Decision axis

Extract

Keep in app

Consumers

Both web and Expo

One app only

Platform dependency

Pure TypeScript

Imports next/*, expo-*, or react-native

Logic character

Input → output, testable in isolation

UI layout, navigation, platform APIs

Interface vs implementation

Same interface and same implementation

Same interface, completely different implementation

The most useful framing we found: the right moment to extract a package is usually when you are about to write the same logic a second time in a different app. Not before that moment — over-engineering a shared package for a single consumer creates structure without value. But definitely not after — retrofitting platform-agnosticism into code that already has platform-specific dependencies is painful.

Related Posts

Try Storyie

Storyie runs on web at storyie.com and on iOS. The shared packages described here are what make a diary written on the web open with identical formatting on mobile — same node types, same theme, same business rules.