What to share across platforms (and what to keep separate): UI component design in a Next.js + Expo monorepo

Storyie Engineering Team
8 min read

After running the same product on Next.js and Expo for nearly a year, we have a clear picture of what belongs in a shared package versus what should stay platform-specific. The short answer: share tokens, logic, and interfaces — not UI components.

What to share across platforms (and what to keep separate): UI component design in a Next.js + Expo monorepo

Running the same product on Next.js and Expo at the same time means you are constantly asking one question: should this live in a shared package or stay platform-specific? After nearly a year of shipping Storyie on both platforms, we have a clear answer — and it is probably more conservative than you would expect.

TL;DR

  • Storyie does not have a shared UI component package (@storyie/ui). We never built one.
  • What we do share falls into three layers: design tokens, platform-agnostic business logic, and domain-specific interfaces.
  • UI rendering is written separately for each platform, full stop.

| Package | What it shares | Examples |
| --- | --- | --- |
| @storyie/theme | Color tokens, font definitions, CSS variable generators | palette.ts, tokens.ts |
| @storyie/lexical-common | Editor commands, config factories, node serialization types | INSERT_IMAGE_COMMAND, createBaseConfig() |
| @storyie/subscription | Plan gating logic, constants, types | canUseFeature(), PlanType |

Everything related to rendering a UI element lives in apps/web or apps/expo — never in a shared package.

Why we never built a shared UI package

The rendering primitives are too far apart

Web renders <div>, <button>, and CSS. Expo renders <View>, <Pressable>, and StyleSheet.create({}). You can bridge the gap with React Native for Web, but that means you own the bridge — and a small team has to actually maintain it.

Bringing in an abstraction layer also adds bundle size and a dependency that has its own release cadence, breaking changes, and edge cases. For Storyie, that cost never made sense.

Components that look the same often are not

Take the like button as a concrete example. On web, it is a server component that receives its initial state via props (for SEO and hydration), then handles optimistic updates on the client. On mobile, it triggers haptic feedback, drives a Reanimated animation, and queues the action when the device is offline.

Same visual shape. Completely different internals. Sharing the component would mean writing Platform.OS === 'web' branches inside something that was supposed to be shared — at which point the sharing is doing nothing useful.

The ROI is negative at small team size

Storyie is built by one developer. Writing the same component twice takes an hour. Designing, implementing, and maintaining a shared abstraction layer takes much longer. The math only changes once the team grows or the number of shared-looking components crosses a threshold that justifies the overhead. We revisit this every few months and keep reaching the same conclusion.

The three layers we do share

Layer 1: Design tokens (@storyie/theme)

We share values, not rendering. The palette, font scales, and spacing definitions live in plain TypeScript with no React dependency at all.

// packages/theme/src/palette.ts
export const palette = {
  primary: '#6366F1',
  background: { light: '#FFFFFF', dark: '#1A1A2E' },
  text: { light: '#1A1A2E', dark: '#E8E8E8' },
  // ...
} as const;

Web reads these values to generate CSS custom properties at build time. Expo passes them straight to StyleSheet.create. Both sides reference the same source of truth for a color change, but neither side dictates how the other renders it.

// packages/theme/src/css.ts — generates CSS variables for web
export function generateCSSVariables(mode: 'light' | 'dark'): string {
  return `
    --color-primary: ${palette.primary};
    --color-background: ${palette.background[mode]};
  `;
}
// apps/expo — passes token values directly to StyleSheet
import { palette } from '@storyie/theme';

const styles = StyleSheet.create({
  container: { backgroundColor: palette.background.light },
});

A color rename happens once and propagates everywhere. How the color gets painted on screen is each platform's own business.

Layer 2: Business logic (@storyie/subscription)

Any logic that gates UI behavior on plan type or feature access lives as a pure function. No platform API, no rendering.

// packages/subscription/src/utils.ts
export function canUseFeature(
  plan: PlanType,
  feature: FeatureKey
): boolean {
  return PLAN_FEATURES[plan].includes(feature);
}

The paywall UI on web looks nothing like the upgrade sheet on mobile. But the condition that triggers them — whether a given plan can use a given feature — is identical. Sharing the logic while keeping the UI separate means one test suite covers both platforms.

Layer 3: Domain-specific interfaces (@storyie/lexical-common)

Storyie uses Lexical for rich text on both platforms. Both the web editor and the Expo-side "use dom" editor need to understand the same node types, or content saved on one platform would silently drop unknown nodes on the other.

What we put in the shared package is everything that describes what the editor does, without touching how it renders:

  • Node definitions for headless-safe nodes (HeadingNode, ListNode, LinkNode, etc.)
  • Serialized type aliases (SerializedImageNode, EditorContent)
  • The Lexical theme: a typed map of class names
  • Config factories: createBaseConfig({ namespace, initialState })
  • Command tokens: INSERT_IMAGE_COMMAND, FORMAT_TEXT_COMMAND, etc.
// packages/lexical-common/src/commands.ts
export const INSERT_IMAGE_COMMAND = createCommand<InsertImagePayload>();
export const DELETE_IMAGE_COMMAND = createCommand<string>();

The command definition is shared. The command handler — which on web updates a <img> in the DOM, and on mobile drives a native upload flow through Cloudflare R2 — is written separately on each side. Same what, different how.

The decision filter

When we are not sure where something belongs, we run through four questions in order:

  1. Is it a pure value or type? Share it — design tokens, type aliases, constants.
  2. Is it a pure function with no platform API dependency? Share it — subscription logic, validation, date utilities.
  3. Does it need the same interface but different implementations per platform? Share the interface only — Lexical command tokens, serialized node shapes.
  4. Does it only look similar on the surface? Keep it separate — write it once for web, once for Expo.

Question four is the most important and the most frequently violated intuition. Visual similarity is not sufficient grounds for sharing. The question is whether the interface is the same, not whether the end result looks alike.

The editor toolbar as a concrete example

Both apps expose a formatting toolbar. Both let users toggle bold and italic, and both let users insert images. On the surface, that sounds like a shared component.

The web toolbar (apps/web/components/editor/EditorToolbar.tsx) is HTML buttons with Tailwind classes, a file input for image upload, and a draft-save button.

The Expo toolbar (apps/expo/components/keyboard-toolbar/) is pinned above the on-screen keyboard, wired to expo-image-picker for camera and photo library access, driven by haptic feedback, and aware of keyboard visibility state through react-native-keyboard-controller.

Same features. Completely different implementations. Abstracting the two into a shared component would either require a sprawling Platform.OS branch or a plugin system that adds more complexity than the sharing removes. We wrote each one independently.

The thing they do share is FORMAT_TEXT_COMMAND from @storyie/lexical-common — the command token that tells the Lexical editor to toggle bold. The command layer is shared. The UI that dispatches it is not.

When a shared UI package is the right call

Our design is not universally correct. There are conditions under which building a @storyie/ui package would make sense:

  • Team of three or more: Enforcing design consistency through shared code becomes more valuable than the maintenance cost, and the overhead distributes across more people.
  • Full commitment to React Native for Web: If you standardize on <View> and <Text> as your base primitives everywhere, shared components fall out naturally.
  • Adoption of a cross-platform UI library like Tamagui or NativeWind: The library provides the abstraction layer, so you are not maintaining it yourself.

Storyie has none of these conditions. We are a small team, we use native Next.js conventions on web, and neither platform has accumulated enough components to justify library overhead. This is a decision we recheck regularly.

The non-negotiable rule for shared packages

Whatever you put in a shared package, it must stay genuinely platform-agnostic. Our own AGENT.md says it plainly:

lexical-common, subscription, theme must remain platform-agnostic:
- No "use client", "use server", "use dom"
- No platform-specific imports (next/*, expo-*, react-native)

Breaking this rule corrupts the package for the other platform. Expo cannot resolve next/navigation. Next.js cannot resolve expo-image-picker. TypeScript surfaces most violations as build errors, but the underlying failure mode is a package that was supposed to be shared becoming quietly coupled to one side.

Summary

| Layer | Share? | Rationale |
| --- | --- | --- |
| Design tokens (colors, fonts) | Yes | Platform-agnostic values with no rendering dependency |
| Business logic (gating, validation) | Yes | Pure functions that work identically on both sides |
| Interface definitions (commands, types) | Yes | What the system does, decoupled from how it renders |
| UI components | No | Rendering is inherently platform-specific |

The rule of thumb: share when the interface is the same, not when the appearance is the same. In a small-team monorepo, leaning toward less abstraction — and writing each platform's UI in that platform's idioms — produces better UX, simpler code, and fewer surprises.

Related Posts

Try Storyie

If you want to see how this plays out from the user side, write a diary entry at storyie.com and open the same entry on the iOS app. The formatting, custom node types, and content round-trip correctly — because the wire format is shared, even though the editors that produce it are not.