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, routes, layouts, providers, gates, skeletons, app-only components | Anything | A screen body a second app renders |
@repo/ui screens/ | Dashboard screen bodies both shells render, and their skeletons | @repo/ui, @repo/app-core, @repo/config | next/*, next-intl, @repo/api, any @/… alias |
@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, the query layer | @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 |
api, link, modals, external, files, host | The typed client, the host's <Link>, its modal store, its URL opener, its file picker, and what it can render | Shared screens |
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 dashboard screen both apps render | @repo/ui/src/screens/, loading its own data - see Shared screens |
| 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.
- One screen body, two shells. Every dashboard screen lives once in
@repo/ui/screens- the admin panel included; the page and the route are route, gate, skeleton, body. Web-only buyers keep adding plain Next pages, desktop-only buyers delete the web ones, and only a build that ships both uses the shared pattern. - 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.
Project Structure
@repo/* package, and what each owns.Shared Screens
Data Fetching
i18n
Desktop App
Mobile app
External Agent (MCP)
Expose your app's AI capabilities to an external agent (the user's own Claude Code or Codex) over an MCP server authenticated by OAuth or an API key, with the same capability set the in-app assistant has.
Shared Screens
Dashboard screen bodies that live in @repo/ui/screens instead of in an app - where one lives, what it may import, and how to add or opt out of one.