Skip to main content

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:
PackageContents
@tuturuuu/users-coreServer logic: route handlers, user-group server data, activity normalization, report/post types
@tuturuuu/users-uiClient logic: hooks and components (approvals view, attendance client, score display, …)
@tuturuuu/ui, @tuturuuu/utilsGenuinely generic pieces (statistic cards, workspace-user link, calendar settings)

Cache Components: connection(), not dynamic

Every Next app runs with cacheComponents (PPR) enabled. export const dynamic and export const revalidate are rejected at build timeawait connection() is the only way to opt into request-time rendering.
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:
Workspace not found: personal
[WorkspaceHelper] Failed to fetch workspace
Error: During prerendering, fetch() rejects when the prerender is complete
That is a real production failure mode (it broke 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.
  • cacheComponents also 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. Add await 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> and await 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:
vi.mock('next/server', async (importOriginal) => ({
  ...(await importOriginal<typeof import('next/server')>()),
  connection: vi.fn().mockResolvedValue(undefined),
}));
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:
// satellite page
const actor = await getSatelliteAppSessionUser('contacts');
const user = actor?.id
  ? await getWorkspaceUserLinkForUser(wsId, actor.id)
  : null;
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

getWorkspace(id) and getPermissions({ wsId }) called without an actor fall back to a cookie-backed Supabase client. In a satellite that client is anonymous — the session is an app-session JWT, not a Supabase auth cookie.
The lookup then returns null, the page 404s, and — because the render aborts with Supabase fetches still in flight — those fetches outlive it and surface as:
Workspace not found: personal
Error: During prerendering, fetch() rejects when the prerender is complete
digest: 'HANGING_PROMISE_REJECTION'
The second error is a symptom, not the cause. Chasing the “hanging fetch” leads nowhere — there is no stray 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:
export async function getContactsWorkspace(id: string) {
  const user = await getSatelliteAppSessionUser('contacts');
  if (!user?.id) return null;
  return getWorkspace(id, { useAdmin: true, user });
}

export async function getContactsWorkspacePermissions(wsId: string) {
  const user = await getSatelliteAppSessionUser('contacts');
  if (!user?.id) return null;
  return getPermissions({ user, wsId });
}
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’s next.config.ts has a fallback rewrite sending any unmatched /api/:path* to web. That is how contacts reaches endpoints it does not own.
Never add a catch-all page ([...slug]) under [locale]/[wsId] in a satellite that proxies /api/*.
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:
[WorkspaceHelper] Failed to fetch workspace: { workspaceId: 'v1', … }
Put non-migrated-route redirects in the app’s 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 checked APPS 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.
Having the namespace is not enough — the namespace can be half-empty.
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. A from '@/' 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 no from clause.
  • Side-effect imports — import '@/lib/dayjs-setup' has no from clause.
  • npm deps of the extracted file must be added to the target package.
  • vi.mock paths silently break: a test mocking @/lib/x stops intercepting once the extracted module imports the package copy directly, so the real module runs unmocked.
Then classify each external dependency:
1

Already a re-export shim

Rewrite the import straight to the package it re-exports.
2

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.
3

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.
4

Mutually coupled modules

Move them together. Preserving their relative layout keeps every cross-import valid with no rewrites at all.
Finish with: 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:
bun add -g vercel
vercel ls contacts --scope tuturuuu
vercel logs <deployment-url> --scope tuturuuu --json
Both contacts incidents above were diagnosed straight from those logs — the workspaceId: 'v1' in a WorkspaceHelper error is what exposed the catch-all shadowing the API proxy.