Skip to contentAsheeUIAsheeUI

The monorepo: one kit, two apps

A working blueprint for a shared AsheeUI kit with a TanStack Start web app and an Expo app: the manifests, the two shells, the two Tailwind pipelines and the typecheck that keeps the kit portable.

This is the arrangement the framework is built for, and the arrangement the example applications use: one shared kit of screens, a web application and a native application, and a shared configuration. The kit imports asheeui and knows nothing about the platform; each application owns its shell, its router, its stylesheet and its build.

The payoff is that a screen is written once. The cost is four pieces of wiring that nothing can remove: the shared package, the two shells, the two styling pipelines, and a typecheck that reads the kit through both renderers' types.

The layout

repo/
  apps/
    web/                  TanStack Start (or Vite + React)   asheeui + @asheeui/web
    mobile/               Expo + Metro + NativeWind          asheeui + @asheeui/native
  packages/
    kit/                  the shared screens, data and theme  asheeui only
  pnpm-workspace.yaml
  package.json

The kit is a package, not a folder reached by a relative path. That is what lets both applications resolve it through the same specifier a published consumer would use, and what makes tsc check it against the umbrella instead of against whichever application happens to import it.

The three manifests

// packages/kit/package.json — the shared kit
{
  "name": "@acme/kit",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "exports": { ".": "./src/index.ts" },
  "dependencies": {
    "asheeui": "workspace:^"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  }
}
// apps/web/package.json
{
  "dependencies": {
    "@acme/kit": "workspace:*",
    "asheeui": "workspace:^",
    "@asheeui/web": "workspace:^",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}
// apps/mobile/package.json
{
  "dependencies": {
    "@acme/kit": "workspace:*",
    "asheeui": "workspace:^",
    "@asheeui/native": "workspace:^",
    "expo": "~58.0.0",
    "react": "^19.0.0",
    "react-native": "0.88.0",
    "nativewind": "^4.2.7"
  }
}

The renderer is a peer of the umbrella and a direct dependency of the application that chose it. The kit must never depend on either renderer: a renderer in the kit's manifest is how a web-only import gets found on a phone.

The two shells

The screens are shared; the navigation is not. This is the platform difference worth designing for first, because it changes the shell rather than one component.

Web: a bar at the top

// apps/web/src/shell.tsx
import { Navbar, Page, PageContent, PageFooter, type Config } from "asheeui";

const links = [
  { label: "Today", href: "/" },
  { label: "Workouts", href: "/workouts" },
  { label: "Nutrition", href: "/nutrition" },
];

export function Shell({ children }: { children: React.ReactNode }) {
  return (
    <Page>
      <Navbar brand="Pulse" links={links} />
      <PageContent>{children}</PageContent>
      <PageFooter>© Pulse</PageFooter>
    </Page>
  );
}

The router is the web's, so the bar should not hard-navigate. Navbar takes a link substitution:

import { Link } from "@tanstack/react-router";
import { RouterLink } from "./router-link";

<Navbar
  brand="Pulse"
  links={links}
  link={{ component: RouterLink, props: {} }}
/>;

RouterLink renders the framework's Link with the same href and forwards the rest; the exact adapter is in the web guide.

Native: a header and a bar at the bottom

// apps/mobile/src/shell.tsx
import { Navbar, Page, PageContent, TabBar } from "asheeui";

const links = [
  { label: "Today", href: "/" },
  { label: "Workouts", href: "/workouts" },
  { label: "Nutrition", href: "/nutrition" },
];

export function Shell({ children }: { children: React.ReactNode }) {
  return (
    <Page>
      <Navbar brand="Pulse" links={links} />
      <PageContent>{children}</PageContent>
      <TabBar items={links} />
    </Page>
  );
}

Both Navbar and TabBar are on asheeui because the umbrella's native branch publishes them. The shell is the one place the two applications differ; the screens below it are the same files.

The shared kit

A shared screen takes its navigation as callbacks, because only the application knows its router:

// packages/kit/src/screens/workouts.tsx
import { Button, Card, Grid, Typography } from "asheeui";

export interface ScreenProps {
  onOpenWorkout: (id: string) => void;
  onNavigate: (route: "today" | "settings") => void;
}

export function WorkoutsScreen({ onOpenWorkout, onNavigate }: ScreenProps) {
  return (
    <Grid columns={1} columnsMd={2} gap="lg">
      {workouts.map((workout) => (
        <Card
          key={workout.id}
          title={workout.title}
          description={workout.description}
          footer={
            <Button onPress={() => onOpenWorkout(workout.id)}>Start session</Button>
          }
        />
      ))}
    </Grid>
  );
}

The kit may use only the portable surface: components, hooks and types the umbrella publishes. The rules that keep it portable are in writing a shared screen; the list is the portable surface.

The two styling pipelines

The web is Tailwind 4; NativeWind is Tailwind 3. They are separate pipelines on purpose, and the umbrella spans both without hiding the split.

Web

/* apps/web/src/styles.css */
@import "tailwindcss";
@import "asheeui/styles";
@source "../../packages/kit/src/**/*.tsx";

asheeui/styles is the umbrella's one-line stylesheet: it imports the web renderer's tokens. The @source line is not optional in a monorepo, because Tailwind 4 scans from the CSS file and the shared package is outside the application's directory; without it, the kit's classes compile to nothing.

Native

// apps/mobile/tailwind.config.js
const asheeui = require("asheeui/tailwind-preset");

module.exports = {
  presets: [asheeui],
  content: [
    "./App.tsx",
    "./src/**/*.{ts,tsx}",
    "../../packages/kit/src/**/*.{ts,tsx}",
    ...asheeui.content,
  ],
};

The spread is required: Tailwind 3 replaces a preset's content with the application's own array, and asheeui.content is the framework's own compiled classes, stated as an absolute glob because pnpm may place the package anywhere. The full Expo setup is in the Expo guide.

One configuration, two manifests

The kit exports the configuration object; both applications pass the same one to the provider.

// packages/kit/src/theme.ts
import type { AsheeConfig } from "asheeui";

export const config: AsheeConfig = {
  color: {
    light: { primary: "#2563EB", background: "#FFFFFF" },
    dark: { primary: "#60A5FA", background: "#0B1220" },
  },
  defaultTheme: "system",
  defaultRadius: "md",
  components: {
    button: { size: "md" },
    card: { variant: "elevated" },
  },
};
// both apps
import { AsheeUIProvider } from "asheeui";
import { config } from "@acme/kit";

<AsheeUIProvider config={config}>{children}</AsheeUIProvider>;

AsheeConfig is the umbrella's type: on the web it is the web renderer's ExternalConfig, on a device the native renderer's, plus the platform-only fields. One object typechecks on both.

The typecheck that keeps it portable

A shared file can compile against the web types and still fail on a device. The kit needs both checks. There are two ways to get them.

Option A: two tsconfigs, two runs

// packages/kit/tsconfig.web.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "jsx": "react-jsx", "moduleResolution": "bundler" },
  "include": ["src"]
}
// packages/kit/tsconfig.native.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "customConditions": ["react-native"]
  },
  "include": ["src"]
}
{
  "scripts": {
    "check-types": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.native.json --noEmit"
  }
}

The react-native condition is what makes asheeui resolve to @asheeui/native's types. The same condition is already in Expo's base tsconfig; state it explicitly in the second config so the check does not depend on which application runs it.

Option B: check from the applications

Each application's tsc reads the kit through its own install. That works, but it is slower and a failure reports the application's path rather than the kit's. Option A is the one that gives a shared package one command.

The two type errors you will meet

Error Cause Fix
process is not defined in a library file The kit's sources use a Node global; the web types allow it, the native config does not Add @types/node to the kit and "types": ["node"]
Cannot find module './global.css' A side-effect CSS import in a .tsx file Add declare module "*.css"; to the kit's ambient types, or import the CSS only in each app

Metro and the workspace

Expo's Metro does not follow pnpm's symlinks out of the box. Two settings matter:

// apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const path = require("node:path");

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");

const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, "node_modules"),
  path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.unstable_enablePackageExports = true;

module.exports = withNativeWind(config, { input: "./global.css" });

Do not set disableHierarchicalLookup = true. With pnpm, a package's own dependencies sit beside its real path in the virtual store; hierarchical lookup is what finds them. Disabling it produces Unable to resolve "invariant" and similar errors from inside React Native.

What goes where

Concern Web app Native app Shared kit
Components and layouts ✓ ✓ ✓
Navigation chrome Navbar at the top Navbar + TabBar destinations and callbacks
Router TanStack Router / Next Expo Router or state none
Styling entry asheeui/styles asheeui/tailwind-preset + global.css className only
Provider root, above the router root, inside SafeAreaProvider the configuration object
Puck editor + renderer renderer only the document type
Platform-only APIs @asheeui/web @asheeui/native never

A page the two platforms disagree about

The studio is the clearest example. On the web it is editable: the editor stores a Puck document in localStorage and the page renders it. On a device the same document is rendered by @asheeui/puck's native renderer, with no editor.

// packages/kit/src/screens/studio.tsx
import { PuckPage, type AsheePuckData } from "@asheeui/puck";

export function StudioScreen({ data }: { data?: AsheePuckData }) {
  return <PuckPage data={data} />;
}

The editor screen is web-only; the renderer is shared. Puck has the full contract.

Scripts and CI

{
  "scripts": {
    "dev:web": "pnpm --filter web dev",
    "dev:mobile": "pnpm --filter mobile start",
    "check-types": "pnpm -r check-types",
    "build": "turbo run build",
    "test": "turbo run test"
  }
}

Two rules keep CI honest:

  1. The kit's typecheck is part of the root check. A shared file that compiles on the web only is the defect this whole layout exists to prevent.
  2. Both applications build. The web build catches a Tailwind @source mistake; the Expo export catches a Metro resolver or NativeWind mistake. Neither build catches the other's.

The reference implementation

The repository's apps/fitness-shared, apps/fitness-web and apps/fitness-mobile are this blueprint with seven screens. The interesting files are:

File What it shows
apps/fitness-shared/src/theme.ts one AsheeConfig, destinations and screen props
apps/fitness-shared/src/screens/* portable screens, no DOM and no React Native primitives
apps/fitness-web/src/shell/AppShell.tsx top Navbar, router link substitution, footer
apps/fitness-mobile/src/MobileShell.tsx header and bottom TabBar, safe areas
apps/fitness-web/src/pages/StudioEditor.tsx the Puck editor, published to localStorage
apps/fitness-shared/src/screens/Studio.tsx the shared Puck page both platforms render