Composition
Surfaces and slices
The two units an app is assembled from, and the invariants they hold.An app is assembled from three kinds of thing: one shell, some
surfaces, and the slices those surfaces require.
The shell is always on. It is the root layout, the providers, the design
tokens, the icon set, the SEO helpers, the OG image routes and the error
contract. It is deliberately small, and it must never import from a
surface or a slice.
A slice is a capability with dependencies, environment variables, providers
and files. auth is the one shipped today: the Supabase client layer under
src/lib/sb, the sign-in routes, the profile API and the rate-limiting
proxy.
A slice degrades gracefully at runtime — missing credentials disable it
rather than crashing — but graceful degradation is the fallback, not the
mechanism. The real mechanism is assembly-time exclusion: a project
scaffolded without auth contains none of those files at all.
A surface is a user-facing feature area that declares the slices it needs.
A surface with requires: [] must work in a project that contains no slice
code whatsoever. landing, docs, store and agency all meet that bar.
The store surface is the clearest illustration of what that constraint
costs and buys. It is a complete storefront — collections, search, product
galleries, variants with per-option pricing and availability, a currency
selector and a working cart — and it needs no credentials for any of it,
because the catalog is read from the filesystem and the cart lives in the
browser.
What it does not have is a commerce provider. Taking money means credentials,
and credentials would break the requires: [] bar. So the surface stops at
one function: checkout in (store)/components/StoreProvider.tsx, which
reports that nothing is wired rather than pretending to charge anyone. A
commerce slice implements that function and swaps the illustrative currency
rates for the provider's own prices. No component above it changes shape —
which is the whole point of a headless storefront.
This repo is the union of every surface and has to build as a single app, so
two surfaces can never own the same route. landing owns /; agency — a
studio site that would naturally live at the root — is served from /studio
instead.
Surfaces in that position declare a rootRedirect. In a composition where
nothing serves /, the scaffolder writes the redirect into the generated
next.config.mjs, and prints which surface it took it from:
The first surface named on the command line that declares one wins.
These hold for every valid combination, and the scaffolder is what enforces
them:
When a slice is left out, the shell still has the lines that referenced it.
Each slice carries absentTransforms: exact-match edits applied to the
generated project to remove those references. If an anchor no longer
matches, the scaffold fails rather than producing a half-wired app —
drift in the shell is reported, not absorbed.
Surfaces carry absentTransforms too, for the same reason: leaving out the
landing surface removes its exports from the content barrel.
Last updated August 27, 2026Shell
Slice
Surface
One owner per route
Invariants
- The generated project passes next build.
- A project without a slice contains none of that slice's files, dependencies, environment variables or providers.
- Providers are wired conditionally — UserProvider exists only when the auth slice does.
- The generated .env.example lists only the chosen slices' variables.
- Every route has exactly one owning surface, so the repo — which contains all of them — builds as a single app.
Unwiring
Edit this page on GitHub