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:
- 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.
- Both applications build. The web build catches a Tailwind
@sourcemistake; 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 |