Where a file lives, what composes what, and where the boundaries fall.
| Contents | Location |
|---|---|
| A pure helper, a driver, a transport, a framework entry point: nothing with a table, a cache tag, an action or UI behind it | lib/ |
Anything with the module shape: actions, queries, types, providers, ui/ | core/<module>/ |
| Reusable UI shared across modules | components/ |
| A cross-module leftover too small to own a module | actions/, hooks/, providers/ |
| A gate, a provider or chrome the route tree mounts directly | its concern's module: core/chrome, core/chat, core/search, core/tenant, core/navigation |
| A route, its metadata, its suspense boundary | the app's app/ tree, and nothing else |
The axis is shape, not subject matter. Tenant, settings, branding, references and storage are infrastructure and still live in core/ because they carry the module shape. There is no infrastructure folder and no logic folder.
An app's app/ tree holds route files only. A body a page mounts lives in the module that owns its concern, never beside the route; the build checks it.
The module root is the logic. Actions, queries, types, contexts and providers sit flat, named by suffix.
core/settings/
index.ts the barrel, zero logic
settings-actions.ts "use server"
settings-queries.ts "use cache"
module-settings-provider.tsx context, provider and accessor together
ui/
branding-content.tsx
brand-logo-form.tsx
index.tsproducts/product-actions.ts. A module spanning several nouns gets one file per noun.ui/ holds components that render, flat, the filename carrying any scope. hooks/ is the module's public hook surface: a hook goes there only when the barrel exports it and another module consumes it. A hook only its siblings use stays flat beside them.index.ts, and it is a pure re-export. ui/ is never reachable through the module barrel; it is always its own sub-path. Server-only surface sits behind a <module>-server.ts exported as /server, so the barrel stays client-safe.Vendor adapters are the one sanctioned deeper nesting. A hub module aggregating interchangeable third-party vendors, payments, shipping, the shop backend, may nest one folder per vendor once that vendor spans several files. Every file inside is vendor-prefixed, stripe-client.ts never client.ts; the contract, the resolver and the shared write path stay flat at the module root. The vocabulary is contract, selector, adapter, resolver.
A module- or route-scoped context is fronted by a named *Provider plus a useX() accessor. Render the provider, never the raw context. The context, the provider and the accessor live together in one *-provider.tsx, under core/ and components/ alike.
"use client";
const ModuleSettingsContext = createContext<ModuleSettings | null>(null);
function ModuleSettingsProvider({ settings, children }) {
return (
<ModuleSettingsContext value={settings}>{children}</ModuleSettingsContext>
);
}
function useModuleSettings(): ModuleSettings {
const settings = use(ModuleSettingsContext);
if (!settings) {
throw new Error("useModuleSettings must be used inside its provider");
}
return settings;
}A provider is a client component fed its data as a prop. The async server component that resolves that data and renders the provider is a *ServerProvider, and a client component cannot import it. A provider holding several kinds gives each a named, typed accessor taking a plain key, never a dotted path string.
Two rules bear the most weight. A stateful client provider sits in the app's root layout, outside the suspense boundary the tenant gate postpones; placed inside, it silently never hydrates. And there are no *Providers bundles: each provider is mounted inline at the level it applies to, so what a layout provides is readable from the layout.
The infrastructure set, branding, identity, messages, navigation, references, settings, storage, tenant, may depend on each other, on components/ and on lib/, and never on products, orders, storefront or any other domain module. A script enforces it in the build. A new core/ folder counts as a domain until it is added to that script's list. When it fails, lift the shared code up, or invert so the domain reaches into the infrastructure module.
The dominant page is synchronous, owns the suspense boundary and the metadata, and composes a shared *Content from the module's ui/ so several apps mount the same body.
export default function ProductFormPage(props: FormPageProps) {
return (
<Suspense fallback={<PageSpinner />}>
<ProductFormContent {...props} basePath="/products" />
</Suspense>
);
}params promise is passed into the content and awaited below the boundary, so the shell stays shared across links.ReactNode props, not render callbacks.A gate wraps children in a precondition. It returns a component on failure and never throws a not-found: under partial prerendering the static shell has already committed, so the throw buys no status code and only aborts validation. The tenant gate returns an unavailable page; the module and role gates return an access page with the reason as its variant. Only the sign-in and onboarding gates redirect.
The signed-in layout is written out, not bundled: the tenant gate, then the current-user provider, the navigation provider, the reference-data providers, the admin shell, and inside it the route gate around the children. The public layout composes its own chrome the same way, with the translation provider outside the tenant gate so its messages also cover the unavailable branch. Never nest a second translation provider to narrow a segment; the one relays the request's single merge, and a nested one re-ships the base to save less than the base.
Never read the session above a boundary in a layout every route shares. Each segment gates itself; awaiting auth there costs every route its prerendered shell.
Guard, then data, then translations, formatters and lookups last, batched:
export async function VisitsContent() {
await protect();
const tenant = await requireTenantSlug();
const visits = await listVisits(tenant);
const t = await getTranslations("visits.list");Never call a translation or locale reader inside a cache scope. Cache raw data keyed by slug or id, and translate at the render layer.
"use server" lives at the top of the *-actions.ts it belongs to, never in a .tsx. Never re-export a type from such a file; the bundler miscompiles it into a runtime reference error."use cache", a tag and a life per exported function."use client" lands on providers at the module root and on ui/ leaves. Every *Content under core/*/ui is a server component.Hide mechanism, the wiring you never want to read. Keep structure, the arrangement of parts that is the meaning of a screen, explicit. A shared component earns its keep when it has one reason to change, takes data rather than mode flags, costs nothing to deviate from, and is aimed at readers who rarely open it. Per-entity files are opened often; do not optimise them for not being read. What looks like duplication after the chrome is extracted is usually uniform use of composable parts. Leave it.
list* returns a collection, never null. get* returns one, or null. The same at every layer.resolve* turns candidates into one effective value through precedence. normalize* guards and shapes one caller-supplied value.*Content is the async suspending body under core/*/ui, and the compound-part container under components/*. A fallback is a *Spinner or a *Skeleton.bun run konsistent && bun run check:infra-deps && bun run check:app-routesThe build runs those and the rest of the guard set ahead of the compile. A new sub-path must be declared in the package's exports by hand.