Next.js static shells, Suspense islands, access gates, and cache tag conventions.
The SaaS app uses Next.js Cache Components and a static-first app shell. The goal is perceived instant navigation: persistent chrome, static page shells, and dynamic work confined to small Suspense islands.
app/[locale]/(app)/layout.tsx owns persistent providers (SidebarProvider, command-palette state, viewport sync) and one AppShell. Session chrome streams into a sidebar island; page children stay mounted as siblings.[orgSlug]/layout.tsx runs slug membership and brand chrome in Suspense. Navigation presentation (organization / customer / admin) is client-side and independent of authoritative access gates.requireOrganizationAccess, requireCustomerPortalAccess, requireSystemAccessAdmin) run inside the smallest useful server component boundary — never in shell layouts.page.tsx files as synchronous compositors. Pages arrange static shell, error boundaries, Suspense, and feature panels; they do not own domain reads.requireOrganizationAccess() and requireSystemAccessAdmin() inside page islands, not layouts.loading.tsx files should reuse those feature skeletons for cold navigations instead of defining a second fallback shape.CentraKit uses singular feature-sliced folders under apps/saas/features/<domain>/:
apps/saas/features/<domain>/<domain>-queries.ts owns cached, server-only reads.apps/saas/features/<domain>/<domain>-actions.ts owns 'use server' mutations (not route-colocated app/**/actions.ts or lib/<domain>/actions.ts).apps/saas/features/<domain>/components/ owns domain UI, request access checks, service orchestration, and colocated skeletons.apps/saas/components/ holds only shared app chrome (sidebar, breadcrumbs, page-shell, kanban presentation, ui/).apps/saas/app/ pages stay sync shells that compose feature components inside Suspense; they contain no domain logic.@workspace/core services and explicit subpaths from bounded contract packages. They never import provider implementations such as @workspace/supabase-work and should not copy direct database-access patterns from example apps.action or *Action callback props when they own reusable async coordination.Feature folders represent user-recognizable domains, not only aggregate roots. tag remains a
standalone feature because Tags have dedicated list/create/detail routes, canonical
tag-queries.ts / tag-actions.ts / tag-cache.ts contracts, and consumers across catalog,
customer, product, and task features. Folding those contracts into one consumer would invert
their ownership and make the other domains depend on an unrelated feature.
Choose one profile before composing a list. The profile describes ownership and streaming behavior; it is not a set of cosmetic component flags.
PageShell, title, and Suspense boundary. The feature panel owns
the DataGrid and uses DataGridListFrame for the standard toolbar, scroll
area, table, pagination, empty state, and bulk-action composition.DataGridListFrame for the same internal toolbar/table/pagination rhythm;
configure DataGridContainer at the embedding boundary instead of adding
visual-mode booleans to the frame.DataGrid, and pass the
server-aware pagination control through DataGridListFrame.pagination.
Server-driven is a data-ownership modifier, not a separate visual frame.DataGrid primitives directly and document the reason near the component.
Do not grow DataGridListFrame with one-off booleans to absorb it.page.tsx remains synchronous. It renders the static page
shell immediately and places only the data/access feature panel behind a
shape-matched Suspense fallback.params, searchParams, cookies, headers, or uncached data in the page shell
merely to configure a grid.Use "use cache: private" for cookie-derived data when cacheable, and pair cached readers with matching updateTag(...) (Server Actions) or revalidateTag(tag, { expire: 0 }) (Route Handlers) calls in mutations. App access and UI permission hints are loaded through one private cached getCurrentAppAccess() payload in the authenticated app layout, then reused by guards, the sidebar, the command palette, and feature-panel permission flag helpers.
Private cache entries live only in browser memory. They are never stored on the server, do not survive a reload, and re-execute on every server render. For private scopes, only cacheLife's stale value affects the client router window — revalidate and expire are inert until a read moves to a shared cache. The built-in presets minutes, hours, days, and max all use the same 5-minute stale, so switching among them does not lengthen private caches.
Use three profiles:
| Profile | stale | When |
|---|---|---|
reference | 15 minutes | Slow-changing tenant data whose every write path is a SaaS Server Action (customers, products, quotes, invoices, users, teams, roles, templates, knowledge, integrations, admin catalogs) |
minutes | 5 minutes | Frequent edits, or writers outside apps/saas that cannot invalidate the SaaS cache (tasks, agenda, files, workflows, billing, usage, inbox) |
seconds | 30 seconds | Chat threads, notification badges, unread inbox counts |
reference is defined in packages/next-config (cacheLife.reference). Tenant list tags are organizationId-scoped builders (for example organization-customers:{organizationId}); numeric detail tags that can collide across tenants also carry the organization id.
Never use public "use cache" for cookie-dependent data.
Prefer await io() from next/cache over await connection() when an island must exclude synchronous nondeterministic values (new Date(), Math.random(), …) from the static shell. connection() blocks prefetches; io() suspends like any other async boundary and stays compatible with partialPrefetching / cachedNavigations.
Most feature data islands do not need either call when they only await cookie/header reads or async queries. Caveat: requireOrganizationAccess() / getCurrentAppAccess() run inside "use cache: private", so cookies stay inside that cache scope — islands that then call new Date() (dashboard overview, agenda default range) still need await io(). Keep connection() only when rendering must wait for a real user request (public token pages, invite accept, similar one-shot handlers).
The proxy enforces session routing and locale rewrites only. Keep proxy work minimal so streaming and Cache Components stay healthy — do not inject an organization slug header, and do not resolve organization membership there; tenant identity is URL params.orgSlug resolved inside Suspense islands.
Locale rewrites still run on Link / Partial Prefetching requests (next-router-prefetch, next-router-segment-prefetch, Purpose: prefetch). Session refresh (getClaims() / cookie rotation) does not: refresh tokens are single-use, and a prefetch racing the click can invalidate the session. Unauthenticated redirects and admin prefix checks run on the real navigation.
Unknown or non-member orgSlug values are rejected in the [orgSlug] layout via OrganizationSlugAccessGate / requireOrganizationSlugAccess in a non-blocking Suspense island beside AppShell, so cold tenant documents can paint chrome immediately. Feature permission failures stay in page Suspense islands:
| Failure | Where it is decided | HTTP status |
|---|---|---|
Unknown or non-member orgSlug | OrganizationSlugAccessGate Suspense island in [orgSlug]/layout.tsx | 404 before the shell streams, otherwise 200 with the not-found UI |
| Missing organization permission for an existing membership | requireOrganizationAccess({ permission }) inside a Suspense island | 404 before the shell streams, otherwise 200 with the not-found UI |
notFound() only produces an HTTP 404 while the response status is still open. Once AppShell has started streaming the status is committed, so in-island notFound() calls may render the not-found UI over HTTP 200. That remains an accepted Cache Components tradeoff for tenant membership and feature gates — do not move those checks into the proxy.
Page-level notFound() and unmatched URLs under a valid shell segment render that segment's not-found.tsx inside AppShell. Layout-level notFound() (unknown or non-member orgSlug) still uses the locale 404 and unmounts the shell.
Cache Components, Partial Prefetch, and View Transitions are one contract. Instant-shell references are Agenda (agenda-hub), Planning (planning-hub), and Files (files-hub). Do not copy public 'use cache' from calendar demos — tenant reads stay "use cache: private".
| Tier | When | What it warms |
|---|---|---|
Default <Link> | Ordinary same-origin navigation | One shared App Shell per route |
IntentPrefetchLink | Unbounded collections (mini-cal days, DataGrid rows, Files rail) | Shell first; upgrades to runtime content on pointer, keyboard focus, or onTouchStart |
prefetch={true} | Bounded, URL-keyed destinations we want resolved before click | URL (date/view/tab) plus the cached slice behind it; uncached data still streams |
Keep prefetch={true} off every mini-calendar day and off unbounded DataGrid rows. The architecture-guard budget in tests/unit/saas/architecture-guards.test.ts is the allowlist. Sidebar eager prefetch is the same rule: org Dashboard, Notifications, Inbox, Chats, Tasks, Agenda, Quotes, Invoices, Customers, and Products (10); portal Dashboard, Quotes, Invoices, and Assets; admin Dashboard. Planning, Files, payments, expenses, and collapsible children stay default Links so the sidebar RSC fan-out stays ≤ 16 (tests/unit/saas/navigation-prefetch.test.ts). Org-switcher rows use IntentPrefetchLink. Navigation-mode homes and breadcrumb parent hubs that match those eager destinations use prefetch={true}.
Hard navigation and Partial Prefetch should commit chrome before the data island. Share one sync shell between the page and loading.tsx:
AgendaHubShell (left rail + mobile toolbar) wraps a Suspense island for AgendaGridData. Period title and the schedule use named View Transitions (agenda-period-title, agenda-schedule); the rail uses default="none". Date/view hops are real Links (?date= / ?view=) so prefetch can resolve the period. Toolbar prev/today/next and the view switcher use prefetch={true}; mini-cal days use IntentPrefetchLink.PlanningHubShell (data-instant-shell-marker="planning-hub") is the page/loading.tsx fallback. The Gantt body streams inside; prev/today/next pan the in-memory window and stay buttons, not date Links.FilesHubShell (rail + main column) is the page/loading.tsx fallback. Scope and folder hops use IntentPrefetchLink. Do not eager-prefetch every folder row.(tabs)/layout.tsx outside page Suspense. Do not blanket-split toolbar vs grid. Add a nested Suspense only when a hub still flashes a full-frame skeleton.Mark instant chrome with data-instant-shell-marker and loaded islands with data-instant-content so e2e (hub-instant-navigation.spec.ts) can assert shell-first paint.
default="none" on stable chrome (sidebar, agenda rail, Files rail, hub toolbars).IntentPrefetchLink + transitionTypes={["nav-forward"]} and text-morph on titles (SharedElementTransition).nav-back. Wrap detail pages in DirectionalPageTransition.SuspenseReveal on every stream.Pair private cache readers with updateTag (SaaS Actions) or revalidateTag(tag, { expire: 0 }) (API writers). Agenda tags the org (organization-agenda:{id}), the fetched range, and the UTC Monday week (organization-agenda-week:{id}:{yyyy-mm-dd}). Mutations updateTag the org plus affected week(s) — source and destination on move when both instants are known.