What a satellite is
apps/web is the central platform app. A satellite is a separate Next app on
its own subdomain that owns one slice of the product and shares the session with
web through cross-app auth.
apps/contacts (contacts.tuturuuu.com, port 7827) is the largest example: it
owns the entire workspace_users CRM surface (/[wsId]/users/* plus
workforce). apps/web no longer has a users section at all.
Shared logic lives in packages so both apps run one implementation:
| Package | Contents |
|---|---|
@tuturuuu/users-core | Server logic: route handlers, user-group server data, activity normalization, report/post types |
@tuturuuu/users-ui | Client logic: hooks and components (approvals view, attendance client, score display, …) |
@tuturuuu/ui, @tuturuuu/utils | Genuinely generic pieces (statistic cards, workspace-user link, calendar settings) |
Cache Components: connection(), not dynamic
This matters more than it looks. Supabase-js issues fetch() under the hood, so
every server-component query is a fetch. A page with no dynamic signal gets
prerendered — and the prerender runs with no cookies:
contacts/users/database).
Rules
- Add
await connection()as the first statement of any authed page or layout that touches Supabase,getPermissions,getWorkspace, or the app session. A dynamic layout does not make its child pages dynamic — each page needs its own call. cacheComponentsalso prerenders GET route handlers. A Supabase-backed GET route with no dynamic signal is statically generated and its response is baked in at build time. Addawait connection()there too; every API route should reportƒ (Dynamic)in the build output.- Where it fits, prefer the true PPR shape: a static shell with the dynamic part
inside
<Suspense>andawait connection()inside the suspended component.apps/meet/[planId]is the reference. - Unit tests call pages and handlers outside a request scope, where
connection()throws. Stub it in the app’s vitest setup while keeping every other export:
bun check does not compile Next apps, so it cannot see any of this. Always run
the app’s real bun run build when you change its routes, pages, or deps.Auth: resolve the actor from the app session
A registered satellite must resolve the acting user from Tuturuuu app-session auth — never from@tuturuuu/utils/user-helper, whose getCurrentUser /
getCurrentWorkspaceUser read Supabase auth directly. The internal-app-auth
guard in bun check enforces this.
When a shared helper needs the actor, give it an injectable userId instead
of letting it resolve one:
getCurrentWorkspaceUser (web) now delegates to that same
@tuturuuu/utils/workspace-user-link helper, so there is one implementation and
web behavior is unchanged.
The actorless-call trap
The lookup then returnsnull, the page 404s, and — because the render aborts
with Supabase fetches still in flight — those fetches outlive it and surface as:
setTimeout/after() anywhere in the tree. The cause
is the missing actor.
This is also why a satellite must not use the shared
@tuturuuu/ui/custom/workspace-wrapper: it calls bare getWorkspace(wsId)
internally, so every page rendering it inherits the bug. That took down the whole
apps/contacts users surface (20 pages) in production.
Give each satellite a src/lib/workspace.ts that resolves the actor once and
threads it through, plus an app-local wrapper that uses it:
scripts/check-internal-app-auth.js enforces both halves (no shared wrapper, no
actorless call). The actorless rule is rolled out per app via
ACTORLESS_CHECK_APPS; apps/calendar, apps/tasks, apps/track, apps/teach,
apps/hive, apps/inventory, and apps/pay still have unaudited call sites.
API proxying — and the catch-all trap
A satellite’snext.config.ts has a fallback rewrite sending any unmatched
/api/:path* to web. That is how contacts reaches endpoints it does not own.
Next checks fallback rewrites only after dynamic routes. So
[locale]/[wsId]/[...catchAll] happily matches
/api/v1/workspaces/personal/settings as locale="api", wsId="v1" — shadowing
the proxy. The [wsId] layout then calls getWorkspace('v1') and every proxied
API call 404s:
proxy.ts middleware instead. It
handles /api in an earlier branch, so it structurally cannot shadow the proxy.
Contacts lists what it owns in CONTACTS_OWNED_ROUTE_PREFIXES; anything else
under /[wsId] redirects to web with path and query preserved.
Add an entry to that list whenever you migrate a module, or the middleware will
bounce the freshly-migrated route straight back to web. Mind prefix-vs-exact
matching: a bare
users entry that prefix-matches makes every /users/* path
look owned, so non-migrated routes 404 instead of redirecting.Translations
A satellite that renders broad shared UI must be in the checkedAPPS list
in scripts/i18n-namespace-check.js, not UNCHECKED_APPS.
Scanning only the app’s own source cannot see namespaces used inside
@tuturuuu/ui or @tuturuuu/satellite. Miss one and it surfaces as a runtime
MISSING_MESSAGE in production rather than a CI failure — which is exactly how
contacts shipped without the notifications namespace.
The shell components (user-nav-client, sidebar-structure-header,
workspace-select, settings-dialog-shell) read their strings through a bare
useTranslations() and then call t('common.dashboard'). A bare translator
carries no namespace argument, so the key-level scan cannot attribute those keys
to a namespace: it only requires them from apps listed in
BARE_ROOT_KEY_APP_SCOPES. Since common was scoped to nobody, the keys were
required of no app at all.
Contacts therefore passed the namespace check (it had common) while missing
850+ keys inside it, and shipped MISSING_MESSAGE: common.dashboard,
common.logout, nav-upgrade-dialog.*, and settings.back_to_app to production.
An app that renders the full dashboard shell belongs in
BARE_ROOT_KEY_FULL_SCOPE_APPS, which requires every bare root-qualified key the
shared packages can request. Use keyExceptions to opt out of product surfaces
the app does not render.
Moving a feature from web to a satellite
Resolve every import to an absolute path before moving anything. Afrom '@/' grep is not sufficient; each of these traps cost a broken build or a
failing test:
- Relative-sibling imports (
../../x) — invisible to a@/grep. - Dynamic imports —
await import('@/…')has nofromclause. - Side-effect imports —
import '@/lib/dayjs-setup'has nofromclause. - npm deps of the extracted file must be added to the target package.
vi.mockpaths silently break: a test mocking@/lib/xstops intercepting once the extracted module imports the package copy directly, so the real module runs unmocked.
Used only by the moving module
Move it along. A satellite maps
@/ to its own src, so the specifier often
needs no change at all.Still used by the origin app
Extract it to
@tuturuuu/users-core (server) or @tuturuuu/users-ui
(client) and point both apps at it. Keep a re-export shim in the origin app
when many files import it; repoint directly when only a few do.connection() on data pages, the owned-routes list, removing the
origin app’s nav entry, an i18n backfill, the TanStack page-override + manifest +
doc counts, then bun check and a real next build.
Deleting pages from
apps/web leaves apps/web/.next/types/validator.ts stale,
so type-check fails on paths that no longer exist. rm -rf apps/web/.next.Debugging a satellite in production
The Vercel CLI is the fastest path to a root cause:workspaceId: 'v1' in a WorkspaceHelper error is what exposed the catch-all
shadowing the API proxy.