Small applications do not have architecture. They have files, and that is fine.
The trouble starts somewhere between feature thirty and feature sixty. Nothing is broken. Every screen works. But adding a field to a form now means touching six files across four folders, and no two people on the team would put the same new component in the same place.
That is not a code quality problem. It is a structural one, and you cannot lint your way out of it.
Organise by feature, not by file type
Almost every large frontend I have worked on started with the same layout, because every tutorial uses it:
src/
components/ ← 180 files
hooks/ ← 60 files
utils/ ← 40 files
services/ ← 30 files
pages/It is tidy for a month. The problem is that it groups things by what they are rather than by what they are for. Working on claims means opening four folders and mentally filtering out everything unrelated. Deleting a feature means hunting for its pieces. Nothing tells you that formatMemberName is used by three modules and formatClaimId by one.
src/
features/
claims/
components/ only claims uses these
hooks/
api/
types.ts
index.ts ← the only thing other features may import
enrolment/
billing/
members/
shared/
ui/ design system primitives
lib/ genuinely generic helpers
api/ client, interceptors, error handling
app/
routes.tsx
providers.tsxNow the answer to “where does this go?” is nearly always obvious, and deleting a feature is deleting a folder.
Each feature exports a deliberate public surface from index.ts. Everything else is private. A lint rule that bans deep imports (features/claims/components/ClaimRow) turns that from a convention into something the build enforces — and it is the single highest-value rule you can add.
Decide which direction dependencies flow
Structure alone does not prevent tangle. You also need a rule about who may import whom, and it needs to be one sentence long so people remember it.
shared/ imports nothing from features/. Ever. If a shared component needs claims logic, it is not shared.
Features import from shared/ freely.
Features avoid importing each other. When two genuinely need to interact, it goes through app/ or through a shared contract — not a direct reach across.
That third rule is the one people push back on, and it is the one that matters. The first time billing imports directly from claims, you have created a cycle in your mental model even if the bundler does not complain. Six months later nobody can change claims without breaking billing.
import { ClaimRow } from '../claims/components/ClaimRow'Billing now depends on the internals of claims. Refactoring claims breaks billing.
import { ClaimSummary } from '@/features/claims'Depends on a published surface. Claims can restructure internally without anyone noticing.
Permissions belong in one place, checked in two
In a serious SaaS application, almost every screen renders differently depending on who is looking at it. An agent sees commissions. An employer admin sees their own employees and nobody else's. A member sees themselves. A support user sees most things but can edit none of them.
The way this goes wrong is predictable: permission checks get scattered inline, then duplicated, then they drift.
// Scattered — and now a copy of this logic exists in five files
{user.role === 'agent' || user.role === 'admin' ? <Commissions /> : null}Give capabilities names, resolve them once, and check the name:
// One definition, derived from role + tenant configuration
export function capabilities(user, tenant) {
return {
'commission:view': ['agent', 'admin'].includes(user.role),
'commission:edit': user.role === 'admin',
'member:edit': user.role !== 'support',
'claim:file': tenant.modules.claims && user.role !== 'support',
};
}<Can do="commission:view">
<CommissionsPanel />
</Can>Three benefits, in order of importance. The rules are auditable — someone can ask “who can edit a member?” and get an answer from one file. Adding a role does not mean a search across the codebase. And the names match what the backend enforces, which makes the two sides comparable.
Everything above controls what is rendered. It stops a support user from seeing a button they cannot use. It does not stop anyone from calling the endpoint directly. The server must enforce the same capability independently, every time. Frontend permissions are user experience; backend permissions are security.
Put one layer between your components and the network
Components should not know whether data arrives over REST or GraphQL, how errors are shaped, or where the retry logic lives. When they do, swapping an endpoint means editing components, and every component invents its own loading and error handling.
component → feature hook → feature api module → shared client
(useClaims) (claims/api.ts) (auth, retry, errors)The shared client owns the cross-cutting concerns exactly once: attaching the token, refreshing it on a 401 without stampeding, normalising error shapes, adding the trace ID that your observability depends on, and timing out sensibly.
// One place where every failure becomes the same shape
async function request(path, options) {
const res = await fetch(path, withAuth(options));
if (!res.ok) {
throw new ApiError({
status: res.status,
code: await errorCode(res),
traceId: res.headers.get('x-trace-id'),
retryable: res.status >= 500 || res.status === 429,
});
}
return res.json();
}Now a component can ask “is this retryable?” without knowing anything about HTTP, and your error UI is written once rather than forty times.
Be strict about what “shared” means
The shared folder is where large codebases go to rot, because everything ends up there and nothing ever leaves.
My test is simple: a component belongs in shared only when at least two features use it and it contains no domain knowledge. A Button qualifies. A DataTable usually qualifies. A MemberCard almost never does, even though three features render members — because the moment it knows what a member is, it will grow a prop for every feature's variation and become unmaintainable.
The failure mode is easy to spot: a shared component with a prop named after a feature.
// This component is no longer shared. It is three components in a trench coat.
<MemberCard
variant="billing"
showClaimHistory={false}
enrolmentMode
compactForAgentView
/>When you see that, the right move is to pull the genuinely common part out — layout, spacing, the card shell — and let each feature compose its own version on top.
State: pick the right kind for the job
Most state confusion comes from treating all state as one thing. It is at least four.
| Kind | Examples | Where it lives |
|---|---|---|
| Server state | Members, plans, claims | A data-fetching library with caching and invalidation |
| URL state | Filters, page number, selected tab | The URL — so it is shareable and survives refresh |
| Session state | Current user, tenant config, permissions | One context, set at boot, rarely changing |
| Local UI state | Is this dropdown open | The component. Nowhere else. |
Nearly every “we need a global state library” conversation I have been in turned out to be server state being hand-cached in a store. Once server state is handled properly, what remains global is usually small enough that a context or two is plenty.
Make the structure enforceable
Conventions that live only in a document decay. Encode them:
- An import boundary rule that fails the build on deep imports and on
shared → features - Path aliases so nobody writes
../../../..and quietly relocates files to avoid it - A file-naming rule so
ClaimRow.tsxis never alsoclaim-row.tsxsomewhere else - A bundle-size check in CI, so the day someone imports a chart library into the login page you find out in the pull request
The question that keeps architecture honest
If a new engineer joined on Monday and was asked to add a field to the enrolment form, how many files would they have to read before they felt safe changing one?
If the answer is two or three, your structure is doing its job. If it is a dozen spread across the codebase, no amount of clean code inside those files will save you — the cost is in the navigation, not the syntax.
Architecture at this scale is not about elegance. It is about making the next change cheap, for someone who was not in the room when you made these decisions.