Client Architecture
How client code is layered - apps hold thin views, @repo/ui draws shared components, and @repo/app-core owns headless behavior behind host seams.
Client code is layered by what it knows, not by what it looks like. apps/web (and apps/desktop, with --desktop) hold thin views, @repo/ui draws shared components, and @repo/app-core owns headless behavior - the hooks, controllers and stores - with no DOM, no Node, and no framework. Your feature code goes in the app until a second surface needs it.
The three layers
| Layer | Holds | May import | Never holds |
|---|---|---|---|
apps/* | Pages, layouts, routing, data fetching, providers, app-only components | Anything | Logic a second app would need |
@repo/ui | shadcn/ui primitives, plus drawn app components in components/app/ | @repo/app-core | Fetching, routing, per-app i18n keys |
@repo/app-core | Hooks, controllers, stores, pure model functions | @repo/config, @repo/utils | JSX, window, document, node:, @repo/ui |
The import arrow runs one way: @repo/ui may import @repo/app-core, never the reverse. app-core compiles for a web worker (lib: ["es2022", "webworker"], types: []), so a DOM or Node reference fails its typecheck immediately.
Seams: how shared logic reaches a router or a session
app-core cannot import your router or your auth client, so each app injects them through seams - small interfaces mounted once at the root by AppCoreProvider.
| Seam | Supplies | Consumed by |
|---|---|---|
navigation | usePathname() and push(href) | Command palette, sidebar, notification deep-links |
identity | useUser() - the signed-in user | Support identify, workspace scope |
notifications | Backend client + toast sink | The notifications read surface |
Seams live in packages/app-core/src/seams/. Each app builds its own slice: apps/web/providers/app-core-provider.tsx wires next-intl's locale-aware router, the desktop app wires its hash router and bridge.
A module needing more than three module-specific seams is telling you it is app-specific. Leave it in the app.
Where does my code go?
| What you are writing | Where it goes |
|---|---|
| A page, route, layout, or server action | The app |
| A component only one app renders | The app's components/ |
| A component both apps render | @repo/ui/src/components/app/, taking every string via props |
| A hook, controller, store, or derivation both apps need | @repo/app-core |
| A pure function with no React at all | @repo/app-core, no seam needed |
Anything touching window, localStorage, or a router | The app, or a seam |
Text never crosses as a translation key. packages/i18n/translations/en/ keeps web.json and desktop.json separate scopes, so a shared component takes its strings through a labels={{ … }} prop, or through the Translator seam with a t the host binds.
Why it is layered this way
- Duplicated behavior drifts. An audit of every authenticated screen found ten divergences between the two apps, four of them user-visible bugs one side had already fixed. Shared behavior cannot drift.
- Screens stay per-app on purpose. The apps are different products - desktop runs local CLIs and a pty, web has admin and billing. Only 7 of 25 screen pairs were true twins, and 6 already share their bodies through
@repo/ui. - A mobile app reuses logic, not JSX. React Native renders neither the DOM nor
@repo/ui, so every line left in a screen body is a line it must rewrite.
Dispatch from Your Code
Run device-side work on a user's paired runner straight from your product code - what pairing authorizes, the run statuses, and how results come back.
Data Fetching
Call the Hono backend from Next.js with typed RPC clients - server-side for authenticated pages, client-side for marketing pages.