The web file taxonomy

The contract suffixes for web code — component, component.ui, hook, types, internal, fixture, tracking — what each one promises and which rule enforces it.

Every suffix on this page is a contract, not a label. Something else in the codebase depends on the promise, and voltro doctor enforces it. That is the whole admission test, and it is why the list is short:

Does another file's correctness depend on this file keeping its promise?

If yes, the promise belongs in the name — you cannot see a contract before you break it otherwise. If no, it is a category, and categories are read out of the file.

The catalogue

Suffix Promise Enforced by
*.component.tsx exactly one component component/one-per-file, component/no-hook-export
*.component.ui.tsx one component, reads only ui/no-write, ui/orphaned, ui/unlinked
*.hook.ts exactly one use* hook (+ types) hook/one-per-file, hook/no-component-export
*.types.ts zero runtime exports types/runtime-export
*.internal.ts only its own subtree imports it internal/foreign-import
*.fixture.ts no production path reaches it fixture/production-import
*.tracking.ts analytics happens nowhere else tracking/outside-tracking-file
*.client.ts it and its imports are browser-safe boot-time import walk, client/not-browser-safe
*.store.ts exactly one defineStore, no server state store/one-per-file, store/mirrors-server-state
*.collection.ts declares content collections (defineCollection); frontmatter schema violations fail the build naming the file the build's collection decode + reference validation
*.consumer.ts declares queue consumers (defineQueueConsumer, @voltro/plugin-queue); loading registers, the plugin's activation starts them the queue runner (decode→DLQ, retry→DLQ, commit-per-message)
*.ws.ts default-exports one raw WebSocket gateway (defineWebSocket), mounting its own upgrade path beside the rpc socket boot discovery on BOTH paths (voltro dev and voltro serve); two gateways on one path refuse the boot

A *.component.tsx promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a const COLUMNS = […] beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a new beside your component is not a second one, and neither is export default Card next to export const Card.

The BOUNDARY rules (internal/foreign-import, fixture/production-import, ui/unlinked) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig paths aliases (read from the nearest tsconfig.json, so a per-app @/* works when you run voltro doctor at the repo root), export … from re-exports, and dynamic import(). A package import is a leaf — the walk stops at the edge of your app.

Contracts that are not suffixes

The admission test above is about the PROMISE, not about the spelling — and three of the framework's conventions carry one without being a suffix on a filename. They are listed here because a reader looking for "what does the framework read out of my tree" would otherwise stop at the table:

Convention Promise Read by
searchParams page export the page's query string decodes through this effect/Schema struct — every field optional or with a default useSearchParams(searchParams), withQuery link typing, and the render-mode scan (a page declaring BOTH renderMode: 'isr' and searchParams is refused)
ogImage page export this route's og:image is a satori JSX template, not a file you ship the build (static → a hashed PNG in dist/assets/og/) and voltro start (ssr → a signed on-demand route, which needs VOLTRO_OG_SECRET)
intercept page export from names the routes a soft navigation may arrive from for this page to render as an overlay above them the client router; a hard load renders the page standalone regardless
grpc.manifest.json (app root) field numbers are checked in and append-only — a deleted field goes reserved, never re-used voltro grpc proto and the gRPC surface wiring, which derive wire identity from it rather than from declaration order
content/<name>/** the files a *.collection.ts declares — markdown with frontmatter, or .json for a data collection getCollection / getEntry, the build's collection artifacts, and the dev server's watcher

The page exports are per-ROUTE and the last two are per-APP, which is the only reason they cannot be spellings: there is nothing to rename.

*.component.ui.tsx — reads, never writes

// OrderRow.component.ui.tsx
import { useCan } from '@voltro/client'
import { useT } from '@voltro/i18n'

export const OrderRow = (props: { order: Order; onCancel: () => void }) => {
  const cancelLabel = useT('orders.cancel')
  const mayCancel = useCan('orders:write')
  return <tr>{/* … */}</tr>
}

Reading is allowed on purpose. Threading translations and permissions through props is prop-drilling — it makes every call site worse without making the component more portable.

Writing is what breaks the contract. A component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading what it does — and that property is exactly what its callers rely on. Lift the mutation into the *.component.tsx that owns it and pass a handler down.

The same file must also be reached from a *.component.tsx, another *.component.ui.tsx, or a page. An unrendered presentational component is carried, reviewed and refactored forever without ever reaching a user; that is how a design system quietly doubles in size.

*.internal.ts — the feature boundary

src/features/orders/
├── index.ts                 # the public surface
├── orderState.internal.ts   # only this directory may import it
└── OrderList.component.tsx

.internal is the promise that refactoring inside that directory breaks nobody. An import from another feature revokes it — silently, and without a single review comment, which is how a boundary rots.

*.types.ts — provably free to import

No runtime export at all. That is not tidiness: it is what makes importing the module cost nothing in the bundle and makes it impossible for it to participate in a runtime import cycle. In a large codebase the second guarantee is the valuable one — an import cycle is only visible when it finally throws.

*.tracking.ts — analytics is confined

// checkout.tracking.ts
import { defineTracking } from '@voltro/client'

export const checkoutTracking = defineTracking('CheckoutButton', {
  onMount: (props) => ({ event: 'checkout_started', orderId: props.orderId }),
  onClick: 'checkout.confirmed',
})

A component then wires it up with useTracking(checkoutTracking, props, sink) — it names a spec, it does not author one. Event names, property bags and the decisions about which fields leave the building all live in one place.

useTracking itself is a hook, so it is not confined — a rule nobody could satisfy is a rule everybody disables. What is confined is defineTracking, the declaration.

The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.

convention/missing-test — why a shallow test is still worth writing

Every suffix that declares a runtime contract also expects a test beside it, named mechanically: Card.component.tsxCard.component.test.tsx. It is an advisory, not an error.

The usual objection is that a per-component test at any real size is low value, and for assertions that is often true. That is not what the rule buys. What it buys is that something mounts the component — and a render loop, a crashing effect, a missing provider or a broken context is invisible until something does.

That is not hypothetical. One app adopting the taxonomy wrote 251 of these, deliberately shallow (it mounts, it performs no domain write, it renders no raw catalogue key). The first run found a page whose breadcrumb effect rebuilt a fresh array literal on every render — effect → context state → re-render → new literal, without end. That one test took 423 seconds and exhausted the heap. Ten sibling pages memoised; exactly one did not, and in a browser the screen had looked usable. After the fix the whole web suite went from 645 s to 57 s.

So write them shallow if you like. The mount is the point.

What deliberately has NO suffix

A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that declared the contract, because declaring it is what makes the promise mean something.

*.store.ts was in this section until defineStore shipped, and the reason it moved out is the rule itself: suffixes follow primitives, never the reverse. While client state was something you brought yourself, a suffix for it would have promised nobody anything. Now voltro check reads those files — one store per file, and no store mirroring server state — so the name carries a contract.

There is no *.form.tsx, and it is the most-requested one. A form is a component; what makes it a form is the schema it validates against, which is already declared and already typed. A suffix would add a rename without adding a checkable promise — "contains a <form>" is not something another file's correctness depends on. If forms ever gain a framework primitive that other code binds to, the rule above will produce the suffix on its own.

Code you did not write

Our rules are ours. A shadcn component arrives via npx shadcn add, follows shadcn's conventions (many exports per file, a hook beside the component), and is overwritten by the next add. Renaming it would break their convention, be undone on the next generator run, and leave the directory half-migrated the moment one file fails to classify.

So the taxonomy applies to what you write, always — and to nothing else. Two signals mark a directory as not-yours:

  1. components.json at the app root. Its aliases.ui names the directory shadcn owns, so a shadcn project needs no configuration at all. Only aliases.ui is honoured — aliases.components is where your own components live too, and exempting it would silence the taxonomy across most of a codebase.
  2. A .voltro-vendored file in any directory, whose first line names the source:
src/vendor/.voltro-vendored     # "copied from acme-design-system v3"

The marker is a file with a reason in it rather than a config list on purpose. A config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. voltro doctor prints every exemption it honoured, so the escape hatch is never silent.

Migrating

voltro update renames what it can decide from the exports alone: one component → *.component.tsx, one hook → *.hook.ts, no runtime exports → *.types.ts. Imports travel with the file.

Two things it will NOT do:

  • A file exporting a component and a hook is left alone and reported. That is the file the taxonomy most wants split, and no codemod can decide which half keeps the name.
  • *.component.ui.tsx is never inferred. "Presentational" is a promise about what a component may do; one that merely happens not to write today has not made it.