One way to build each kind of screen, one set of tokens, and one folder layout, so every page looks and reads the same. New pages follow this; existing pages move to it as they're touched.
Principles
- Content first. The sidebar recedes (a touch darker, muted labels); the work area gets the contrast.
- Structure felt, not seen. Separate with spacing and surface tone; keep only the borders that carry meaning.
- Dense but calm. App text 13β14 px, steady row heights, one accent, status colors only for status.
- Warm neutral grays in light and dark. The brand gradient (indigo β coral) is for marketing and moments (checkout success, empty states), never working UI.
- Keyboard first. βK everywhere; shortcuts shown with
Kbd. - Small motion. 150β200 ms ease-out;
prefers-reduced-motionrespected.
Tokens (src/app/globals.css)
| Kind | Tokens |
|---|---|
| Surfaces | background, card, popover, muted, accent, sidebar (+ -foreground) |
| Lines and focus | border, input, ring (brand at 45β55%) |
| Status | success, warning, info, destructive, each with -foreground (text) and -subtle (background) |
| Elevation | shadow-raised (menus, popovers), shadow-overlay (dialogs, sheets); cards stay flat |
| Type | Geist Sans / Mono; display 48/56 (marketing), h1 24, h2 18, h3 15, body 14, small 13, micro 12; tabular-nums for money, counts, times |
| Space | 4 px grid; page padding 24 (16 on phones), section gap 32, control gap 8β12 |
| Radius | --radius scale: sm controls, md cards, lg dialogs |
| Icons | lucide, 16 px (14 in dense rows), stroke 1.75 |
Status badges come from one map, StatusBadge (src/components/status): the same word always gets the same tone (success, warning, info, destructive, secondary, outline).
Route files (Next 16)
| File | Use |
|---|---|
layout.tsx | the shell and its data (workspace check, sidebar, tabs) |
page.tsx | server component: reads, then renders its view from src/views/<route> |
loading.tsx | the page's shape with Skeleton (StateLoading) |
error.tsx | client; StateError with retry() and the error digest |
not-found.tsx | StateMissing for that kind of thing, with a way back |
forbidden.tsx | forbidden(): signed in but not allowed (a workspace you're not in) |
unauthorized.tsx | unauthorized(): sign in or sign up, then back to the same page |
global-error.tsx | the root layout itself failed |
forbidden() / unauthorized() need experimental.authInterrupts (on in next.config.ts).
Cache Components
cacheComponents and partialPrefetching are on. Every segment starts with export const instant = false (allowed to block), data pages and layouts call await connection() before reading the database, and the session check (authenticationCheck) is request-time. To make a route instant: move its session/cookie/params reads into a component inside <Suspense>, then remove instant = false. Note that page state (inputs, open menus) now survives navigating away and back.
Route groups
| Group | Pages | Layout |
|---|---|---|
(marketing) | home (later pricing, legal, contact, blog) | header + footer |
(authentication) | sign in, sign up | centered form |
(public) | /form/<token>, /invite/<token> | logo + one card (LayoutPublic) |
(account) | /account (also as a modal through @modal) | account shell |
app/[workspace] | the app | sidebar shell (LayoutApp) |
admin | admin | admin shell |
Folder map
Every folder mirrors src/app:
src/app/[locale]/β¦β routes only.src/views/<route>β the page's UI (ViewAppWorkspaceStudio,ViewAdminWorkspaceDetailβ¦). A page's own pieces live beside it (views/app/[workspace]/studio/[id]/window/canvas/node.tsx).src/components/<domain>β pieces reused by several pages (components/uifrom shadcn,components/state,components/status,components/dataset/access,components/account,components/dialog,components/flow).src/actions/<route>β mutations ('use server',aprefix,[true, data] | [false, message]):actions/app/[workspace]/studio,actions/admin/workspace,actions/(public)/invite.src/forms/<route>β react-hook-form/zod schemas reused by forms and actions.src/controllers/<domain>/<sub>β reads and business logic (no'use server').src/lib/<domain>/<sub>β modules with a static list each (flow/node,flow/template,workspace/permission,secret,configuration,i18n,database/schema/<domain>/<sub>).src/types/<mirror>β shared types (TCamelCase).
Naming and data shapes
- Files and folders kebab-case; domain scope is nested folders (
browser/live/create.ts), never dashes. - Functions and variables
domainSubdomain(workspaceAccess,flowNodeDot). - Data shapes nest by domain:
{ tax: { isInclusive } }, not{ isTaxInclusive }. Suffix fields stay flat:xxxAt,xxxId,xxxMs,xxxBy. Booleans areis+ one word; a compound becomes an object or an enum.
Screen patterns
- Page header:
h1title, one-line description, actions on the right (primary last). - List: toolbar (search, filters, view switch) β
Tableon md+, item rows on phones βEmptywith one action β pagination. Row: icon, name, meta, status badge, time, a menu. - Detail: header + route-based tabs from the layout + content.
- Settings:
FieldGroup/Field(label and description, control); a sectionCardonly where an action belongs to it (Delete workspace). - Dialogs:
AlertDialog/DialogConfirmationfor confirmations (dangerous ones repeat the name),Dialogfor short forms,Sheet/Draweron phones. - Forms: every
<form>hasmethod="post". Submitted before the page hydrates, the browser would otherwise send the fields as a GET query (passwords and keys in the URL, history and server logs); a POST only re-renders the page. After hydrationonSubmit(react-hook-form) handles it as usual. - States: loading, error, forbidden, unauthorized, not found β the
components/statepieces, never hand-made per page.
Less DOM, same design
- Layout with
flex/grid+gapon the parent; no wrapperdivfor spacing or around a single child. - Semantic elements carry the layout (
main,header,section,nav,ul/li,dl);Fragment, never<></>. - shadcn's composed parts (
Field,Empty,InputGroup,ButtonGroup,Kbd) instead of hand-built stacks;Cardonly when grouping means something (never card in card). - State styling through
data-*/aria-*/group-*/has-*variants; container queries for cards that change shape.
FAQ
Where does a new page's UI go?
In src/views/ at the same path as its route, named View + the path ([workspace] β Workspace, [id] β Detail).
When is something a component instead of part of a view?
When two or more pages use it. Then it goes to src/components/<domain>.
Can I add a color?
Use the tokens. A new meaning gets a token pair in globals.css (light and dark), never a literal color in a page.
How is a database model declared?
const Thing: Model<IDatabaseThingDocument> = mongoose.model<IDatabaseThingDocument>("Thing", ThingSchema, undefined, { overwriteModels: true }); in src/lib/database/schema/<domain>, and a discriminator with { overwriteModels: true } too. The dev server's hot reload then compiles a changed schema again (a new field and its default apply at once), instead of keeping the model it compiled first until a restart.