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:

Two shapes of ownership

Contacts kept shared logic in packages because apps/web still needed some of it. apps/forms (forms.tuturuuu.com, port 7828) is the other shape: a clean hard move. The forms product had no consumers outside itself, so the feature moved wholesale with no extracted package, and apps/web retained nothing but redirects. Forms also owns every API route it needs/api/v1/workspaces/[wsId]/forms/* and the public /api/v1/shared/forms/* — instead of proxying them to web. Two things forced that and are worth copying:
  • Forms tables live in the Postgres private schema (getPrivateFormsClient(supabase) => supabase.schema('private')), so every read needs the admin client. An anon/RLS client cannot reach them at all, which means proxying to web buys nothing.
  • The public form-filling surface at /f/<shareCode> is served to anonymous respondents. See the public-API note under API proxying below.
Legacy public links (tuturuuu.com/<locale>/shared/forms/<shareCode>) get a permanent 308 from web’s proxy, issued before the auth proxy runs so respondents and social crawlers are never bounced to /login. apps/infrastructure follows the same hard-ownership rule for internal platform operations. Infrastructure-only routes such as AI-credit administration, translation tooling, and holiday administration live in that app. Shared route shapes that its UI needs, including the root-admin workspace list and workspace secret management, also have local Infrastructure handlers protected by the Infrastructure app session and root-workspace membership. Do not add a new Infrastructure UI call that depends on an apps/web fallback rewrite.

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:
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:
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. Workspace-user link repair must not require an optional platform display name. New auth users initially have only a public.users.id; the repair RPC derives a workspace profile name from display name, private full name, handle, email, and finally a stable generic fallback. Membership remains mandatory, and the repair does not create or broaden workspace access.

Personal workspace aliases in APIs

API routes that accept personal must preserve the same authenticated actor while resolving that alias. Use the actor-explicit helpers from @tuturuuu/satellite/workspace-access: they verify the intended target app before resolving personal, UUID, handle, or internal workspace identifiers, and fall back to the request-scoped Supabase session only when no app-session token is present. An invalid, expired, or mismatched app-session token must not fall through to another identity. Passing only a cookie-backed client on a satellite loses the app-session identity and incorrectly reports User not authenticated. Route handlers must keep both values returned by the auth context: use the explicit user for getPermissions({ user, wsId }), and the returned admin client for workspace-scoped data operations. A request property is not an actor for a satellite app session. An admin-backed app-session client has no auth.uid(). Therefore a satellite must not call a public RPC wrapper whose authorization derives from auth.uid(). Call the private actor-aware RPC and pass the verified user ID in its actor argument while preserving the route response shape.

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:
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:
scripts/check-internal-app-auth.js enforces both halves (no shared wrapper, no actorless workspace, permission, or alias-normalization call) across every registered satellite. Compatibility fallback is centralized in audited auth wrappers and the shared workspace-access helper; individual routes are not allowlisted.

One actor for the workspace shell

The workspace layout is the authentication boundary. Pass its verified user ID into NavbarActions and render notifications from that same identity in expanded, collapsed, and mobile navigation. Do not independently re-resolve authentication for the notification bell: two resolvers can disagree and hide the bell while the rest of the layout remains authenticated.

Reserved route placeholders

Selection sentinels such as Contacts group ID ~ are navigation state, not database identifiers. Parallel routes can begin rendering child pages before a parent selection gateway replaces them, so app-local workspace wrappers must stop those child loaders before any UUID filter runs. The parent gateway remains responsible for group selection and selected-group navigation.

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/*.

The proxied web route must accept app sessions

A web route that satellites proxy has to opt into app-session actors. createClient(request) deliberately returns an unauthenticated client as soon as the forwarded request carries the host-only tuturuuu_app_session cookie, so resolveAuthenticatedSessionUser / getPermissions({ request }) find no user and the route answers 401 Unauthorized — from every satellite, while the identical code keeps working on tuturuuu.com.
Opt in explicitly, then authorize on workspace permissions (the calling app grants nothing by itself):
The tell-tale symptom is one action failing inside an otherwise-working panel. Workspace member invites returned “Unauthorized” from Inventory and every other satellite while the member list, roles, and invite links worked, because those routes had been migrated and members/invite had not. A narrow allowlist behaves the same way for the apps it omits: { targetApp: 'teach' } on the user-group storage route 401’d every Contacts request for group files. When you fix one route, sweep the rest of its surface:

Public (anonymous) API routes on a satellite

The standard satellite proxy.ts /api branch calls refreshAppSessionForRequest, which returns ok: false when a request carries no session credentials at all — and the branch turns that into a 401. Any genuinely public endpoint must be excluded, or it is unreachable for the anonymous users it exists to serve.
apps/storefront and apps/forms both do this: match the public paths, and skip the app-session refresh unless the caller happens to be signed in.
Abuse protection for those routes is Turnstile plus rate limiting inside the handler, not the app session. Note the same trap applies to page auth: forms treats /f/<shareCode> as a public path so anonymous respondents are not redirected to /login. Related: a signed-in user on a satellite has an app-session JWT, not a Supabase cookie. Code that asks supabase.auth.getUser() to decide “is this person signed in” silently answers no for everyone. Forms’ response-copy email flow hit exactly this and had to read the actor from the app session. 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:
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. For narrower shared surfaces, register the owning apps in the matching scoped consumer set. The workspace members-and-roles panel uses SATELLITE_WORKSPACE_SETTINGS_APPS, so bun i18n:namespace-check requires its ws-members.* and ws-roles.* keys from every satellite that mounts that panel. The source scanner also follows typed shared helpers such as t: (key: string) => string; otherwise helper-owned labels can evade a normal useTranslations() scan and render their key paths in production. Keep conditional product-only branches covered with an explicit key exception rather than weakening the whole consumer scope.

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