GenerateSaaS

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.

mounts seams mounts seams apps/web @repo/ui apps/desktop @repo/app-core

The three layers

LayerHoldsMay importNever holds
apps/*Pages, layouts, routing, data fetching, providers, app-only componentsAnythingLogic a second app would need
@repo/uishadcn/ui primitives, plus drawn app components in components/app/@repo/app-coreFetching, routing, per-app i18n keys
@repo/app-coreHooks, controllers, stores, pure model functions@repo/config, @repo/utilsJSX, 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.

SeamSuppliesConsumed by
navigationusePathname() and push(href)Command palette, sidebar, notification deep-links
identityuseUser() - the signed-in userSupport identify, workspace scope
notificationsBackend client + toast sinkThe 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 writingWhere it goes
A page, route, layout, or server actionThe app
A component only one app rendersThe 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 routerThe 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.

On this page