| name | module-conventions |
| description | Per-module file-type convention for this stack (TanStack Start + Drizzle/Neon + Better Auth) — which role file each piece of code belongs in (.server.ts · functions.ts · .browser.ts · schema.gen.ts), deep-path imports (no barrels), eager instances, env as single source. Use when creating or refactoring a module or feature slice, deciding where a piece of code goes (a server function, a server-only instance, a browser client), or co-locating a Drizzle schema. |
Every module is a deep, contained folder reached by deep path — there is NO index.ts barrel. Files are named by their ROLE, so the layout is identical across modules and the filename is the interface. The .server.ts/.browser.ts suffixes are framework-enforced compile boundaries, not naming habits.
Quick start — the file taxonomy
| Role | File | Boundary | Examples |
|---|
| Server-only eager instance / server-only logic | {descriptor}.server.ts — Avoid: service.ts, db.ts, lib.ts (no boundary; drags Pool/secrets into the client bundle) | .server.* ENFORCED — a client import is a build error | auth/auth.server.ts, shared/db.server.ts, notifications/send.server.ts |
Server functions (createServerFn) + their middlewares | functions.ts — Avoid: api.ts, actions.ts, handlers.ts (obscures the isomorphic-DCE contract) | isomorphic (no suffix) | auth/functions.ts, users/functions.ts |
| Browser-only code that SSR routes import | {descriptor}.browser.ts — Avoid: {descriptor}.client.ts (looks like a boundary suffix but TanStack Start enforces none — a false compile guarantee), hooks.ts, utils.ts | none — NOT .client.ts | auth/client.browser.ts, users/collection.browser.ts |
| Drizzle tables (hand-written / generated) | schema.ts / schema.gen.ts — Avoid: models.ts, tables.ts, entities.ts (the drizzle-kit glob only finds schema*) | co-located, found by a drizzle-kit glob | auth/schema.gen.ts |
| Components | {component}.tsx — Avoid: index.tsx (a barrel by another name), a shared components/ bucket | — | — |
| Barrel re-export | — | — | Never. Avoid: index.ts, index.tsx — there is no barrel; import the role file by deep path |
Routes in src/routes/ own page UI; they delegate all logic to modules by deep path.
Which file does this code go in?
- Runs only on the server AND no client file imports it — a heavy eager instance (
betterAuth(...), drizzle(new Pool(...)), new Resend(...)) or server-only logic → {descriptor}.server.ts. Write the bare eager export: export const auth = betterAuth(...). The file boundary keeps it out of the client bundle (build error if violated) — no lazy guard needed.
- A
createServerFn the client invokes, or a middleware → functions.ts. Import the .server.ts instance as a VALUE — it gets DCE'd from the client bundle because it's only touched inside .handler()/.server() callbacks. Any session-requiring server fn guards itself with the shared requireAuth middleware (server fns are public HTTP endpoints; route guards don't protect them).
- Browser-only code that SSR-rendered routes import (a Better Auth client, a TanStack DB collection) →
{descriptor}.browser.ts. Server imports here are import type only.
If unsure why a suffix is required, read references/import-protection.md — the three TanStack Start findings the compiler forced.
Hard rules
- No barrels. No
index.ts. Cross-module imports use the deep path @/module/file; intra-module imports stay relative (./file). The filename is the public surface — name it descriptively by content, never generic lib.*/utils.*.
- Eager, never lazy. No
??= singletons, no getX() init wrappers. The .server.ts boundary (not laziness) is what keeps server payloads out of the client. new Pool(...) doesn't connect at construction; a TanStack DB collection doesn't fetch until its first subscriber (startSync defaults to false), so constructing it in SSR module-eval does nothing.
env is the single source. App/runtime code reads the validated env from @/shared/env — never process.env / import.meta.env ad hoc, INCLUDING the Better Auth CLI-chain files (auth.server.ts, db.server.ts, send.server.ts). With barrels gone, a deep import { env } from "@/shared/env" pulls only env.ts, not a whole module graph, onto the CLI-eval path. Exactly three blessed exceptions remain, each because it loads outside Vite:
src/router.tsx — import.meta.env.DEV, a Vite compile-time DCE flag not modelable by t3-env.
drizzle.config.ts — drizzle-kit / Vercel build; does its own process.loadEnvFile(".env.local") in the module body (an import { env } would hoist above it).
notifications/templates/*.tsx — process.env.APP_URL; loads in React Email's dev preview AND the CLI chain, where @/ and import.meta.env don't resolve.
Should this module exist? — the deletion test
Before you carve out a module (or defend one in review), imagine deleting it and inlining its body at every call site:
- If the complexity reappears across N callers — the same guard, the same eager-instance wiring, the same query shape copied N times — it was earning its keep: a deep module (a lot of behaviour behind one deep-path file). Keep it.
- If the complexity vanishes — the "module" was a one-line re-export, a rename, a thin pass-through that every caller could inline without loss — it was a shallow module: pure interface tax. Fold it back into its single caller.
Corollaries for seams:
- One adapter is a hypothetical seam; two adapters make it real. Don't split a module behind an interface for a variation that doesn't yet exist — no
notifications/send.server.ts + notifications/send-sms.server.ts fork until a second channel actually ships (contrast the additive template pattern below, which stays in ONE file until a channel is real).
- The interface is the test surface. A module is tested THROUGH its deep-path file, not past it. If a test has to reach around the filename to a private helper, the seam is in the wrong place — reshape the module, don't punch through it.
Rejected framings
- Barrels (
index.ts re-exports). Rejected. A barrel makes the folder the interface and hides which file a symbol lives in, so a single @/module import can drag an entire module graph — including a .server.ts Pool or secret — onto a client or CLI-eval path.
- Lazy singletons (
??=, getX() init wrappers). Rejected. Laziness is a runtime guard bolted on to compensate for a missing compile boundary — .server.ts already IS that boundary, so the lazy wrapper buys only indirection.
.client.ts as a boundary suffix. Rejected. It reads like the mirror of .server.ts, but TanStack Start enforces nothing on it — a false compile guarantee that lulls you into importing server code from it. Browser-only code uses the honestly named .browser.ts.
- Type-based buckets (
components/, services/, hooks/, lib/, utils/). Rejected. Grouping by kind scatters one feature across six folders and lets anything import anything. Group by feature/domain slice; a one-off sub-piece stays inline in its role file until a second caller earns it its own file.
- Depth measured as lines-of-implementation ÷ lines-of-interface. Rejected (it rewards padding the body). Depth here means leverage: how much behaviour a caller gets per unit of interface they must learn — see the deletion test above.
Schema co-location
Tables live in their OWNER module — {module}/schema.ts (hand-written) or schema.gen.ts (generated, lint-excluded). drizzle.config.ts discovers them by glob — no central schema list:
schema: ["./src/**/schema.ts", "./src/**/schema.gen.ts"],
The db instance aggregates per-module namespaces so db.query.* stays typed and extensible:
import * as authSchema from "@/auth/schema.gen";
export const db = drizzle({ client: pool, schema: { ...authSchema } });
A consumer that needs a table imports it directly (import { user } from "@/auth/schema.gen") — never through the db instance file, so a client module never drags in the Pool.
Multi-channel notification templates
A template under templates/ exports ONE value per channel, so a new channel is additive:
export const subject = "Your code";
export const Email = ({ otp }: Props) => ( );
export default Object.assign(Email, { PreviewProps: { otp: "123456" } });
Related skills
/dobby:data-processing — the write-side recipe: forms (an entity's schema.ts is the single source for validation rules + messages) plus mutation UX.
/dobby:data-fetching — the collection recipe (the server fn in functions.ts → the eager collection in collection.browser.ts → LiveQuery).
Acceptance checklist