Skip to content

modernx-admin — First-run (tenant-less) workspace behavior

A brand-new signup has an org and a session but no connected Magento store yet. Until 2026-07-30 that state caused a 500 storm on /signup and /login, because a tenant-scoped bootstrap query ran unconditionally on every route. Two conventions now keep a tenant-less workspace from crashing anywhere in the app.

Convention: client-side tenant bootstraps only mount inside (admin)

Section titled “Convention: client-side tenant bootstraps only mount inside (admin)”

app/providers.tsx is the ROOT client provider tree — it mounts for every route, including unauthenticated /login and /signup. It owns only QueryClientProvider and ToastProvider. Anything that bootstraps tenant-scoped data (like StoreContextProvider, lib/store-context.tsx) must NOT go here.

StoreContextProvider instead mounts from components/platform/AdminProviders.tsx, which app/(admin)/layout.tsx renders inside the authenticated shell — after the layout’s session/getCurrentUser() check. A signed-out request to /login or /signup never reaches this tree, so it never fires StoreContextProvider’s listStoreViews query.

Why this matters: listStoreViews is a 'use server' action, and Next.js server actions POST to the current page URL. Mounting its caller at the root meant the action ran as POST /signup and POST /login — not an admin route — for every page load, authenticated or not.

When adding a new client provider that reads tenant-scoped data, mount it under AdminProviders/(admin), not app/providers.tsx.

Convention: tenant-scoped server actions degrade to empty, never throw

Section titled “Convention: tenant-scoped server actions degrade to empty, never throw”

getActiveTenant() returning null (no connected store) is an expected, common state — every fresh signup starts there — not an error. Actions that depend on an active tenant follow a “degrade to empty, never throw” contract for exactly that case:

  • listStoreViews() (lib/backends/magento/actions/stores.ts) catches MagentoNotConfiguredError and resolves to []. Any other error (transport, auth, schema) still rejects — this only absorbs the no-tenant case, not general Magento failures.
  • app/(admin)/page.tsx resolves hasConnectedStore server-side (tenant !== null) and skips its pinned-reports query entirely when there’s no tenant, same pattern getActiveTenant() itself documents.

This is intentionally narrow: it lives in the action layer, not in tenantMagentoFetch itself, because every other merchandising action relies on that throw to drive its own per-page error UI. Widening the catch into the shared fetch layer would silently turn real store failures into “no data” states across the app.

Result: the dashboard has a real first-run state

Section titled “Result: the dashboard has a real first-run state”

HomeDashboard (components/dashboard/HomeDashboard.tsx) takes a server-resolved hasConnectedStore prop. When false, it renders a “No connected store yet” empty state with an indigo “Connect a store” CTA to /stores, and every merch query (listRules, listBanners, listRuleProposals, listRecentHistory, listFeedHealthAlertsAction) is passed enabled: hasConnectedStore so none of them fire for a tenant-less workspace — instead of a grid of '—' tiles with silent failed queries underneath.

Source: modernx-admin PR #399.