GenerateSaaS

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.

A shared screen is one implementation of a dashboard page, living in @repo/ui/screens rather than in an app. The page that renders it stays thin: route, gate, skeleton, <Screen/>. Marketing, docs and auth pages are unaffected.

With the desktop app on, both shells render the same body: the web page and the desktop route are each route, gate, skeleton, <Screen/>. That is what the layer buys - one change lands in both apps instead of two that drift.

That includes the admin panel, which both shells route by default. A screen can be gated two independent ways, and the register carries both: a feature flag says this BUILD has no such screen, so no route is registered at all, while a role says the build has it and this VIEWER may not open it, so the route exists and its body is withheld. Folding the two together takes a whole surface off a shell the moment a flag moves.

Where a screen lives

packages/ui/src/screens/<area>/<screen>-screen.tsx   // the body ("use client")
packages/ui/src/screens/<area>/<screen>-screen.test.tsx
packages/ui/src/screens/<area>/<screen>-skeleton.tsx

Register it in packages/ui/src/screens/register.ts, naming the shells that route it.

A body only one shell routes is a twin again with an extra indirection, and the register is what catches that: the two route tables cannot import each other, so parity is asserted against it.

The shape of a screen

  • Takes only route params it needs (id) and host flags it cannot derive. Never data - it loads its own.
  • Reads config from @repo/config for every gate it owns, so no two shells can disagree.
  • Gets text from a translator its shell binds, with keys in packages/i18n/translations/en/shared.json under screens.*.
  • Exports a skeleton; the web loading.tsx renders the same one the body shows while its first query is pending.

What it may import

AllowedRefused by lint
@repo/ui, @repo/app-core, @repo/confignext, next/*
@tanstack/react-query (a peer of both packages)next-intl, use-intl
@phosphor-icons/react/ssr@tanstack/react-router
Anything reached through a seam@repo/api, any @/… app alias

@repo/api is refused inside src/screens/** even as a type. The one exemption is packages/ui/src/test-support/**, where the screen harness builds a real typed client: it is a devDependency there and never reaches a runtime path.

The seams a screen uses

Everything host-specific arrives through AppCoreProvider (see Client Architecture).

SeamWhat it gives the screen
apiThe host's typed RPC client, baseUrl for the few surfaces that show it, and an optional uploadFile for the one request the hosts send differently (a multipart file)
navigationusePathname(), push(href), useSearchParams(), canNavigate(href)
linkThe host's own <Link>, taking a plain href string
modalsuseModals().open(name, data) over the shared modal registry
externalOpens a URL outside the app (a new tab, or the OS browser)
filesThe file picker
hostchromeDrawsTitle, useRole(), useOrgRole(), webUrl, and per-host feature flags
authuseSession() (the editable profile fields, an optional refresh(), and impersonatedBy when the session is one), the organization capability, an optional security capability for the account screens, and an optional admin capability for the admin ones - each a structural subset of the host's client
analytics, activeProjectOptional; absent means a no-op and null. analytics.useConsent() is optional too, and absent answers "no banner, already agreed"
workspaceThe active organization, project and settled key, plus readable and a notice for a settled workspace the host cannot read (an outage, or no project)
chatOptional; useController() hands the chat screen the host's conversation, and useAutomationRunChat() opens an automation run as one - each answers null where the host has no chat

host.chromeDrawsTitle decides the heading. The web dashboard's top bar already draws the page title, so a body renders its own PageHeader title only when the flag is false.

Two flags carry a real divergence between the shells rather than a preference. host.features.markupSink is false on the desktop, whose renderer may never render machine-composed HTML, so a body shows text instead. And a link the backend wrote lives in the web app's URL space, so a body routes it with navigation.push(href) when canNavigate(href) is true and opens host.webUrl + href outside the app when it is not.

Data

Screens load through one TanStack Query client, mounted by each shell, with keys from queryKeys in @repo/app-core/query/keys. Every key starts with a scope tuple (user, organization, project), so signing out is one queryClient.clear().

Adding a screen

Write the body and its skeleton under packages/ui/src/screens/<area>/, taking text through the translator prop your shell binds.
Put its copy in en/shared.json under screens.<area>. Never edit another locale by hand.
Render it from the web page under its config.routes.* path.
Render it from the desktop route too, under that same path, so the sidebar config, the command palette and deep links resolve identically.
Add it to register.ts, and write one jsdom test with renderScreen from @repo/ui/test-support/screen-harness.

Your own screens

Nothing forces this pattern on code you write. Add pages in apps/web as usual - server components included - and reach for a shared body only when a second shell has to render the same thing.

Your buildWhat to do
Web onlyAdd pages in apps/web as usual.
Desktop onlyAdd desktop screens, and delete the web dashboard pages you do not ship.
BothUse the shared pattern above, so one change lands in both apps.

To take one screen back into an app, move the body into that app's own components and delete its register.ts entry.

On this page