Flow
Decisions

0020 — Frontend layering: where a file goes in a Next.js app

Every Next.js app in frontend/ uses one layered structure — routes, component tiers, hooks, services, logic, util, const, type — with the client/server split carried by the folders.

Decided. Every Next.js app in frontend/ uses the same layout, the one already running in tool-app, building-app, and devops-app. A file's home is decided by what kind of thing it is, not by which feature it belongs to:

LayerHoldsRule
app/Routes onlyA page.tsx picks a view and passes route params. No UI, no fetching
src/component/base/Primitives — button, dialog, image wrapperNo domain knowledge, no fetching
src/component/partial/Composites reused across views — cards, drawers, app chromePresentational; the parent owns behavior
src/component/layout/The shell a route tree renders insideOne per shell. DashboardLayout, not AppShell
src/component/view/One screen, mounted by one routeOwns the screen's state and calls services
src/hook/provider/<Name>/React context<Name>Context.tsx, <Name>Provider.tsx, use<Name>.ts, index.ts
src/hook/<concern>/Everything else statefuluseX.ts. Never a bare hook file at src/ root
src/service/<domain>/Reaching anything over HTTPDefault-export object of async methods; fetch lives in <Domain>ServiceBase.ts
src/service/<domain>/server/Server-only data access<Name>ServiceServer.ts, first line import "server-only"
src/logic/Business rules with no React and no HTTPPure functions, callable from either side of the boundary
src/util/Generic helpers — format, parse, cookies<Name>Helper.ts, default-export object
src/const/Values with no behaviorNamed exports
src/type/Types not owned by a service moduleDomain subfolders; style/*.d.ts for MUI augmentation

Component mechanics, which are as load-bearing as the folders:

  • A component is a folder, Name/Name.tsx, plus Name.module.scss when it is styled and index.ts always. Child components go in Name/component/Child/, recursively.
  • index.ts re-exports the default under its own name — export { default as Name } from "./Name". A folder of children gets a component/index.ts of export * from "./Child" lines. Imports therefore always name a folder, never a file.
  • function Name() {} then export default Name at the bottom. Not export default function.
  • "use client" goes on the component that needs it, not on page.tsx. Pages stay server components so they can be async and hold metadata.
  • SCSS modules sit beside the component and read tokens from styles/scss/theme.module.scss, the one file that also :exports them to TypeScript.

What this changes today

local-app was written flat: src/component/AppShell/ holding the shell, the side nav, the drawer, and a context; src/component/WaveMenuButton/; and nav.ts, sql.ts, serviceBus.ts, useSnapshot.ts, server/postgres.ts loose at src/ root. Its sibling devops-app, in the same repository, already had the structure above. The move:

WasIs
component/AppShell/AppShell.tsxcomponent/layout/DashboardLayout/DashboardLayout.tsx
component/AppShell/SideNav.tsxcomponent/layout/DashboardLayout/component/Navigation/
component/AppShell/NavDrawer.tsxcomponent/partial/NavigationDrawer/ (chrome) + component/partial/DrawerNav/ (links)
component/AppShell/workspaceLoading.tsxhook/provider/Workspace/
component/WaveMenuButton/component/partial/WaveMenuButton/
src/nav.tssrc/const/nav.ts
src/sql.ts, src/serviceBus.tssrc/service/local/{DatabaseService,BusInspectorService}.ts
src/server/postgres.tssrc/service/local/server/PostgresServiceServer.ts
src/useSnapshot.tssrc/hook/serviceBus/useSnapshot.ts
200-line app/service-bus/page.tsxcomponent/view/ServiceBus/ behind an 11-line page

fetch calls that were inline in views moved behind DatabaseService and BusInspectorService. That is the layering doing work rather than a rename: the SQL view had response-shape checks and error-string fallbacks copied at three call sites, and they collapsed into one place.

Alternatives

  • Let each app choose, and only require internal consistency. Rejected on the evidence that produced this ADR. local-app's shell is a port of devops-app's, and the port had already drifted — one file where there were four, a hand-rolled loading context where a provider existed, AppShell where every other app says DashboardLayout. The next port would have been from whichever app the author happened to open.
  • Group by feature (src/serviceBus/{component,hook,service}). Genuinely better for deleting a feature whole, and it is what we would likely pick for a greenfield app with no siblings. Rejected because three apps already ship the layer-first structure and the shell is copied between them; a fourth arrangement makes every future port a translation.
  • A shared frontend/common package for the shell. Rejected as premature for the same reason common/ exists for Go only once a second caller does (0018): the shells differ in what the top bar holds and what the drawer knows, and extracting them now would encode devops-app's CMS-shaped nav into an app that has two static links. Revisit when two apps need the same shell, not a similar one.
  • components/, hooks/, services/ (plural). Rejected only because the existing apps are singular. Nothing else recommends either.

Why

Three of the four Flow frontends independently arrived at this structure, which makes it the convention whether or not it is written down. Writing it down buys two things: a port stops being a judgement call, and "where does this go?" has an answer that does not depend on which app you learned first.

The layer boundaries also carry the client/server split, which is the one mistake in a Next.js app that is expensive rather than untidy. src/service/<domain>/server/ marked with import "server-only" is the difference between a build error and shipping a database password to a browser bundle.

What it rests on

  • Nothing enforces any of this. ESLint in these apps configures quotes and semicolons; it has no rule about folders, import paths, or barrels. A reviewer noticing is the whole mechanism, which is the same position 0019 ends in.
  • The reference apps are not perfectly consistent with each other. building-app has a view-local logic/ folder in one view and loose .tsx files at another view's root; tool-app has child components with no index.ts. Where they disagree, this ADR picked the majority form; those files are the minority, not a second sanctioned pattern.
  • local-app is the only app in this repository that follows this fully. devops-app matches on structure but has no view/ layer yet, because it has no views — its routes render placeholders. The claim "three apps agree" is about tool-app and building-app in their own repositories, read at the time of writing, not about CI-verified state here.
  • The base/ tier is unpopulated in this repository. It is in the table because the reference apps have it and a component will eventually need it, not because anything here demonstrates it.

Consequences

  • A page.tsx that grows UI is a defect, not a style preference. The Service Bus screen was 200 lines of view inside a route file and nothing flagged it.
  • Every fetch from a component becomes a service method. This is the rule most likely to be skipped under time pressure, and the one that pays off, because the response-shape handling is what gets copied.
  • Adding a context means four files. That is heavier than a useState in the shell, and it is what makes the provider testable and reusable instead of private to one layout.
  • New frontend apps start from frontend/seed/ (theme stack and agent skill) and copy shell/service patterns from devops-app or intelligence-app. When the platform generator arrives it generates this shape; until then it is copied by hand and nothing checks it.

On this page