ADR-0026 Project structure
Company standard repository layout — a Bun workspace with each app under apps/, layer folders inside every app, server-only code fenced in src/server/, environment variables parsed with T3 Env, one naming scheme for files, and squash-merged pull requests titled as Conventional Commits.
- Decision
- Bun workspace; layer folders; src/server/ fencedBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Builds on
- 0002App framework
- 0003Deploy target
- 0005Input validation
- 0008ORM / query layer
- 0009Auth
- 0010Rate limiting
- 0011Styling
- 0012Data fetching & state
- 0013Forms
- 0014Testing
- 0015Observability
- 0016CI/CD
- 0017Reverse proxy & TLS
- 0019Virtual lists
- 0020Charts
- 0021Markdown
- 0022Syntax highlighting
- 0023Debounce & throttle
- 0025AI SDK
- 0035Runtime & toolchain
- 0036API layer
Context
Section titled “Context”Earlier ADRs already fix pieces of an app’s layout, each where it needed one:
src/start.tsholds the global request middleware (ADR-0036, ADR-0010);src/router.tsxcreates the router and itsQueryClient(ADR-0012)src/components/ui/holds shadcn/ui andsrc/styles/app.cssthe theme (ADR-0011);src/components/form/,chart/,virtual/andmarkdown/hold our wrappers (ADR-0013, ADR-0020, ADR-0019, ADR-0021)src/lib/markdown.tsandsrc/lib/highlight.tsare client-safe helpers (ADR-0021, ADR-0022);src/server/ai/is server-only (ADR-0025)- Tests sit next to their code as
*.test.ts,*.test.tsxand*.int.test.ts, with end-to-end specs ine2e/(ADR-0014); workflows live in.github/workflows/(ADR-0016)
Nothing draws the whole. Each new repository fills the gaps its own way — where server functions go, where tables live, where API routes go, what a file is called, what a commit message says — so moving between products means learning a new map. Three forces shape the answer:
- TanStack Start builds one codebase twice, for the server and for the browser (ADR-0002). Its import protection fails the build when a
*.server.*file reaches the browser bundle, and it can fence whole folders the same way. A layout that keeps server-only code in one folder turns a leaked database client into a build error - Business rules must be testable without HTTP (ADR-0014): server functions and API route handlers only parse, authorize and call plain modules. The layout needs a home for those modules
- One image runs in every environment (ADR-0016): Nodus injects configuration as environment variables at runtime, and the app validates them with Zod (ADR-0005). Anything Vite inlines at build time is the same in staging and production
Options considered
Section titled “Options considered”Repository shape
Section titled “Repository shape”1. One package per repository
Section titled “1. One package per repository”Pros: nothing to configure; the scripts, the Dockerfile and the lockfile sit side by side.
Cons: a second deployable (a worker, an admin app) or a shared package later means moving every file into apps/ in one large, conflict-prone change.
2. A Bun workspace from the first commit
Section titled “2. A Bun workspace from the first commit”Pros: every repository has the same shape whether it holds one app or five; a second app or a shared package is a new folder, not a migration. Bun runs scripts across the workspace (bun run --filter), and one lint and one format config at the root cover every package (ADR-0035).
Cons: a root package.json and --filter in scripts even when there is only one app; the Docker build context becomes the repository root.
Code layout inside an app
Section titled “Code layout inside an app”A. Feature folders (src/features/<domain>/)
Section titled “A. Feature folders (src/features/<domain>/)”Pros: one domain’s components, server functions and queries sit together; deleting a feature deletes a folder.
Cons: shared code still needs layer folders, several of them pinned by earlier ADRs, so every repository runs two systems; each new file starts a debate about which feature owns it; server-only files are scattered, and each must carry the .server.ts suffix to be protected.
B. Code beside its route (-components/ folders inside src/routes/)
Section titled “B. Code beside its route (-components/ folders inside src/routes/)”Pros: a page and the code only it uses are neighbours.
Cons: code used by two routes has no home; src/routes/ fills with folders that are not routes.
C. Layer folders
Section titled “C. Layer folders”Pros: one rule for every file — the folder says what the code does; it matches the paths earlier ADRs pinned; all server-only code sits under src/server/, which one import-protection rule fences.
Cons: one domain spreads across several folders: components/invoices/, functions/invoices.ts, schemas/invoices.ts, server/services/invoices.ts and so on.
Keeping server code out of the browser
Section titled “Keeping server code out of the browser”i. The .server.ts suffix only (Start’s default)
Section titled “i. The .server.ts suffix only (Start’s default)”Pros: no configuration. Cons: every server-only file must remember its suffix; one that forgets is bundled for the browser without a warning.
ii. The suffix plus the src/server/ folder
Section titled “ii. The suffix plus the src/server/ folder”Pros: everything under src/server/ is fenced by where it lives; the suffix still covers the rare server-only file elsewhere.
Cons: one line of plugin configuration, which must restate Start’s default because a custom list replaces it.
Parsing environment variables
Section titled “Parsing environment variables”a. A Zod schema by hand (schema.parse(process.env), as Start’s guide shows)
Section titled “a. A Zod schema by hand (schema.parse(process.env), as Start’s guide shows)”Pros: no dependency.
Cons: every app re-solves the same details: empty strings in .env files fail number schemas and skip defaults, nothing stops a secret from being read in browser code, and a public variable without the VITE_ prefix is silently undefined in the browser.
b. T3 Env (@t3-oss/env-core 0.13.11)
Section titled “b. T3 Env (@t3-oss/env-core 0.13.11)”Pros: a thin layer over our Zod schemas (Standard Schema) that solves exactly those details: emptyStringAsUndefined, a clientPrefix enforced in types and at runtime, and an error when browser code reads a server variable. TanStack’s own app generator offers it as its environment add-on.
Cons: pre-1.0, so pinned to an exact version; one more dependency for what is otherwise a dozen lines.
Commit messages
Section titled “Commit messages”I. Free-form
Section titled “I. Free-form”Pros: nothing to learn or check.
Cons: the history on main can’t be scanned or filtered by kind of change; people, coding agents and Renovate each write their own style, and nothing marks the change someone must act on before a deploy.
II. Conventional Commits on every commit, checked by a commit-msg hook
Section titled “II. Conventional Commits on every commit, checked by a commit-msg hook”Pros: every commit on every branch is typed; a bad message is caught before it is pushed.
Cons: commitlint and a hook in every repository; git commit --no-verify skips it; with squash merging, branch commits never reach main, so most of what it checks is thrown away.
III. Conventional Commits on the pull request title, squash-merged
Section titled “III. Conventional Commits on the pull request title, squash-merged”Pros: one typed commit per pull request on main; one CI step with no dependency checks it, and nobody can skip it; branch commits stay free for work in progress.
Cons: main keeps the squashed commit, not a branch’s steps; the merge must use the title, which is a repository setting on GitHub.
Decision
Section titled “Decision”A Bun workspace with each app under apps/, layer folders inside every app, and all server-only code under src/server/, fenced by import protection. This is the company standard layout for all new apps. (Amended 2026-11-13: this ADR first chose a pnpm workspace run with Vite+; ADR-0035 moved the toolchain to Bun.)
The example below is a product called billing with one domain, invoices.
Select a file or folder to see what it is for. Coloured icons mark paths an earlier decision already fixed.
- billing
- .github
- workflows
- ci.yml
- pr-title.yml
- apps
- web
- drizzle
- e2e
- sign-in.spec.ts
- public
- src
- components
- chart
- form
- invoices
- invoice-table.test.tsx
- invoice-table.tsx
- markdown
- markdown.tsx
- ui
- button.tsx
- virtual
- functions
- invoices.ts
- hooks
- use-invoice-filters.ts
- lib
- highlight.ts
- markdown.ts
- utils.ts
- routes
- _authed
- invoices
- $invoiceId.tsx
- index.tsx
- api
- auth
- $.ts
- v1
- invoices.ts
- __root.tsx
- _authed.tsx
- index.tsx
- schemas
- invoices.test.ts
- invoices.ts
- server
- ai
- adapter.ts
- chat.ts
- api
- v1
- invoices.int.test.ts
- invoices.ts
- http.ts
- db
- schema
- auth.ts
- index.ts
- invoices.ts
- client.ts
- relations.ts
- services
- invoices.int.test.ts
- invoices.ts
- auth.ts
- env.ts
- telemetry.ts
- valkey.ts
- styles
- app.css
- router.tsx
- routeTree.gen.ts
- start.ts
- .env
- .env.example
- bunfig.toml
- components.json
- Dockerfile
- drizzle.config.ts
- instrumentation.ts
- package.json
- playwright.config.ts
- tsconfig.json
- vite.config.ts
- docs
- decisions
- packages
- .gitignore
- .oxfmtrc.json
- .oxlintrc.json
- AGENTS.md
- bun.lock
- compose.yaml
- package.json
billing
./
One repository per product, a Bun workspace from the first commit. Run everything from here with bun; bun run --filter @billing/web <script> targets one app.
Set by
This decision
Show as table
| Path | What it is for | Set by |
|---|---|---|
./ | One repository per product, a Bun workspace from the first commit. Run everything from here with bun; bun run --filter @billing/web <script> targets one app. | This decision |
.github/ | Forge configuration. | This decision |
.github/workflows/ | The one portable workflow set, run by GitHub Actions and Forgejo Actions alike. Never add .forgejo/workflows/. | 0016CI/CD |
.github/workflows/ci.yml | Checks and tests every commit, then builds one immutable image per app for Nodus to deploy. | 0016CI/CD |
.github/workflows/pr-title.yml | Fails a pull request whose title is not a Conventional Commit. The title becomes the squash commit on main. | This decision |
apps/ | One folder per deployable. Each app builds its own Docker image; a second app, such as a worker, is a sibling of web/. | This decision |
apps/web/ | The TanStack Start app: the UI, server functions and the API routes, in one container. Its package.json is named @billing/web. | 0002App framework |
apps/web/drizzle/ | Migrations written by drizzle-kit generate. Committed and reviewed, never edited by hand; CI and deploy apply them. | 0008ORM / query layer |
apps/web/e2e/ | Playwright specs for each critical journey, run against the production build. | 0014Testing |
apps/web/e2e/sign-in.spec.ts | E2E specs end in .spec.ts and live outside src/, the only folder bun test searches. | 0014Testing |
apps/web/public/ | Files served as they are from the site root, such as favicon.ico and robots.txt. Nothing that needs a build step. | This decision |
apps/web/src/ | Application code, in layer folders. The folder says what a file does; the file name says which domain it belongs to. | This decision |
apps/web/src/components/ | React components. Shared wrappers sit in their own folders; product components sit in one folder per domain. | This decision |
apps/web/src/components/chart/ | Our thin wrappers around TanStack Charts, each with its table twin. | 0020Charts |
apps/web/src/components/form/ | Form fields bound to TanStack Form and shadcn/ui Field. | 0013Forms |
apps/web/src/components/invoices/ | The components of one domain, in a folder named after it. | This decision |
apps/web/src/components/invoices/invoice-table.test.tsx | Component test, next to the component, run by bun test in happy-dom. Focus, keyboard and pointer behavior is tested in e2e/. | 0014Testing |
apps/web/src/components/invoices/invoice-table.tsx | One component per file, named after it: invoice-table.tsx exports InvoiceTable. Named exports only. | This decision |
apps/web/src/components/markdown/ | The one Markdown component every screen uses. | 0021Markdown |
apps/web/src/components/markdown/markdown.tsx | Renders model and user text with raw HTML off. | 0021Markdown |
apps/web/src/components/ui/ | shadcn/ui components on Base UI, written by the shadcn CLI and ours to edit. | 0011Styling |
apps/web/src/components/ui/button.tsx | As shadcn add button wrote it, then edited freely. | 0011Styling |
apps/web/src/components/virtual/ | Wrappers around TanStack Virtual for long lists and large grids. | 0019Virtual lists |
apps/web/src/functions/ | Server functions (createServerFn), one file per domain. Each parses its input with a shared schema, checks access and calls a service; no business rules here. | This decision |
apps/web/src/functions/invoices.ts | getInvoices, createInvoice and the rest of the domain. Safe to import from components: the browser gets RPC stubs. | This decision |
apps/web/src/hooks/ | React hooks shared by several components. use-<name>.ts exports use<Name>. | This decision |
apps/web/src/hooks/use-invoice-filters.ts | Reads and writes the list's filters in the URL. | This decision |
apps/web/src/lib/ | Client-safe helpers with no React and no server imports. | This decision |
apps/web/src/lib/highlight.ts | The one highlighter and its explicit language list. | 0022Syntax highlighting |
apps/web/src/lib/markdown.ts | The link and image policy every Markdown render shares. | 0021Markdown |
apps/web/src/lib/utils.ts | cn() from shadcn/ui. A helper used by one file stays in that file. | This decision |
apps/web/src/routes/ | File routes. A route file holds its loader, its guards and its page, composed from components/; nothing else lives here. | This decision |
apps/web/src/routes/_authed/ | Pages behind sign-in. The _ prefix adds no URL segment. | This decision |
apps/web/src/routes/_authed/invoices/ | /invoices and everything under it. | This decision |
apps/web/src/routes/_authed/invoices/$invoiceId.tsx | /invoices/:invoiceId. Params are camelCase and say what they hold: $invoiceId, not $id. | This decision |
apps/web/src/routes/_authed/invoices/index.tsx | /invoices, the list. Paging, sorting and filters live in the URL. | This decision |
apps/web/src/routes/api/ | Server routes for everything consumed as HTTP. Route files only wire; the handlers live in server/api/. | 0036API layer |
apps/web/src/routes/api/auth/ | Better Auth's endpoints. | This decision |
apps/web/src/routes/api/auth/$.ts | Splat server route: hands every /api/auth/* request to auth.handler(request). | 0009Auth |
apps/web/src/routes/api/v1/ | The versioned API. Callers authenticate with a token, never the session cookie. | 0036API layer |
apps/web/src/routes/api/v1/invoices.ts | /api/v1/invoices: maps GET and POST to the handlers in server/api/v1/invoices.ts. | This decision |
apps/web/src/routes/__root.tsx | The document shell: <html>, head tags, providers and the error boundary. | This decision |
apps/web/src/routes/_authed.tsx | Pathless layout: checks the session once for every route in _authed/. | This decision |
apps/web/src/routes/index.tsx | The home page, /. | This decision |
apps/web/src/schemas/ | Zod schemas shared by forms, server functions and API routes. Client-safe. | 0005Input validation |
apps/web/src/schemas/invoices.test.ts | Unit test, next to the file it tests. | 0014Testing |
apps/web/src/schemas/invoices.ts | The domain's input and filter schemas, derived from the tables with drizzle-orm/zod where the shapes overlap. | This decision |
apps/web/src/server/ | Server-only code. Import protection fails the build if anything here reaches the browser bundle. | This decision |
apps/web/src/server/ai/ | The AI SDK's server side. | 0025AI SDK |
apps/web/src/server/ai/adapter.ts | The model provider this product chose in its own ADR. | 0025AI SDK |
apps/web/src/server/ai/chat.ts | The chat handler and its tools. | 0025AI SDK |
apps/web/src/server/api/ | The API handlers that the route files in routes/api/ call. | 0036API layer |
apps/web/src/server/api/v1/ | One file of handlers per resource; the version is in the path. | This decision |
apps/web/src/server/api/v1/invoices.int.test.ts | Integration test that calls the handlers with a Request and a caller, against real Postgres and Valkey. | 0014Testing |
apps/web/src/server/api/v1/invoices.ts | The /api/v1/invoices handlers: validated with the shared schemas, calling the same service as the server functions. | This decision |
apps/web/src/server/api/http.ts | What every handler shares: readJson, readQuery and the ApiHandler type. | This decision |
apps/web/src/server/db/ | Everything that talks to Postgres. | 0008ORM / query layer |
apps/web/src/server/db/schema/ | Drizzle tables, one file per domain. drizzle.config.ts reads this folder. | This decision |
apps/web/src/server/db/schema/auth.ts | Better Auth's tables, written by auth generate. Regenerate, never edit. | 0009Auth |
apps/web/src/server/db/schema/index.ts | Re-exports every table for the client and for Drizzle Kit. | This decision |
apps/web/src/server/db/schema/invoices.ts | The invoices table. Table names are plural, like the URL. | This decision |
apps/web/src/server/db/client.ts | The Bun.SQL pool, wrapped by traceSql, and the Drizzle client; the only file that connects. | 0008ORM / query layer |
apps/web/src/server/db/relations.ts | Every relation in one defineRelations() call, so joins across domains have one home. | This decision |
apps/web/src/server/services/ | Business rules, one file per domain: plain functions that take their dependencies (database, clock, clients) as parameters. | This decision |
apps/web/src/server/services/invoices.int.test.ts | Integration test that calls the service directly. | 0014Testing |
apps/web/src/server/services/invoices.ts | The rules for invoices, shared by the server functions and the API handlers. | This decision |
apps/web/src/server/auth.ts | The Better Auth instance: Drizzle adapter, Valkey secondary storage and plugins. | 0009Auth |
apps/web/src/server/env.ts | Every server variable, declared once with T3 Env's createEnv and read from process.env at runtime. No other file reads process.env. | This decision |
apps/web/src/server/telemetry.ts | Span helpers: trace and traceServerFn for src/start.ts, traceSql for the Postgres client, and withSpan for storage and mail calls. Each goes when Bun or Start traces that hop itself. | 0015Observability |
apps/web/src/server/valkey.ts | The shared ioredis client for rate limits and sessions. | 0010Rate limiting |
apps/web/src/styles/ | Global styles. | This decision |
apps/web/src/styles/app.css | Tailwind's entry point and the theme tokens; the only global stylesheet. | 0011Styling |
apps/web/src/router.tsx | Creates the router and its QueryClient. | 0012Data fetching & state |
apps/web/src/routeTree.gen.ts | Written by the router plugin on every dev and build run. Committed, never edited. | This decision |
apps/web/src/start.ts | Global request middleware, run before every page, server function and /api/* call: the request span and ID, rate limits, the CSRF check, sessions, and the API caller for /api/v1/*; plus a span for every server function. | 0010Rate limiting |
apps/web/.env | Local secrets and settings, loaded by Bun automatically. Git-ignored; start from .env.example. | This decision |
apps/web/.env.example | Every variable env.ts reads, with safe placeholder values. Committed. | This decision |
apps/web/bunfig.toml | bun test settings: root = "src", so e2e/ is never collected, and lcov coverage. | 0014Testing |
apps/web/components.json | The shadcn/ui CLI's settings: the Base UI style and the @/ aliases. | 0011Styling |
apps/web/Dockerfile | Builds this app's image: built with Bun, run on Bun's distroless image as a non-root user. Its build context is the repository root, so the lockfile is in reach. | 0016CI/CD |
apps/web/drizzle.config.ts | Points Drizzle Kit at src/server/db/schema/ and drizzle/. | 0008ORM / query layer |
apps/web/instrumentation.ts | Starts OpenTelemetry and wraps fetch. The image runs it with bun --preload, before the server, the worker and the migration runner. | 0015Observability |
apps/web/package.json | @billing/web, with the dev, build, test, test:int and test:e2e scripts. | 0035Runtime & toolchain |
apps/web/playwright.config.ts | Runs e2e/ against the production build, with Postgres and Valkey in containers. | 0014Testing |
apps/web/tsconfig.json | Strict, with the @/* alias for src/*. | This decision |
apps/web/vite.config.ts | The Start plugin with its import protection, and Nitro's bun preset. | 0035Runtime & toolchain |
docs/ | Product documentation. | This decision |
docs/decisions/ | This product's own ADRs, in this site's format: deviations from these defaults, and choices left to each product, such as the AI provider. | This decision |
packages/ | Code imported by two or more apps, one folder per package, named @billing/<name>. Starts empty: code moves here when a second app needs it. | This decision |
.gitignore | Ignores node_modules/, build output and .env files. | This decision |
.oxfmtrc.json | Format settings for every package. | 0035Runtime & toolchain |
.oxlintrc.json | Lint rules for every package. A package that needs different rules gets an overrides entry here, never its own config. | 0035Runtime & toolchain |
AGENTS.md | Where things go, which commands to run and the stack, for people and coding agents alike. Copied from the site's AGENTS.md template. | This decision |
bun.lock | The only lockfile. Text, so it is reviewed in diffs; bun ci fails when it is out of date. | 0035Runtime & toolchain |
compose.yaml | Local Postgres and Valkey, on the same image tags as production. | 0003Deploy target |
package.json | The workspace root: private, no app code. packageManager pins Bun; workspaces lists apps/* and packages/* and holds the version catalog. | 0035Runtime & toolchain |
Concretely:
-
One repository per product, a Bun workspace from the first commit.
apps/<name>/holds each deployable — the TanStack Start app isapps/web/— with its ownDockerfile, image andpackage.json, named@<product>/<name>.packages/<name>/holds code that two or more apps import and starts empty: code moves there when a second app needs it, not before -
The root holds the workspace, not app code: a
package.jsonwithpackageManager(the Bun version),workspaces.packagesand the version catalog inworkspaces.catalog, andbun.lock(ADR-0035);compose.yamlfor local Postgres and Valkey (ADR-0003),.github/workflows/,docs/decisions/andAGENTS.md..oxlintrc.jsonand.oxfmtrc.jsonat the root hold the lint and format rules for every package; a package that needs different lint rules gets anoverridesentry there, never its own config. Each app’s ownvite.config.tsholds its build, and itsbunfig.tomlits test settings (ADR-0014) -
Inside an app, folders are layers. The folder says what a file does; the file name says which domain it belongs to. Imports flow down this table, and only server function handlers and the API route files under
routes/api/reach intoserver/:Folder Holds Imports from routes/Route files: loader, guards and the page, composed from components/components/,hooks/,functions/,schemas/,lib/components/Shared wrappers in their own folders; product components in components/<domain>/components/,hooks/,functions/,schemas/,lib/hooks/React hooks shared by several components functions/,schemas/,lib/functions/Server functions, one file per domain schemas/,lib/;server/inside handlers onlyschemas/Zod schemas shared by forms, server functions and API routes (ADR-0005) lib/lib/Client-safe helpers, with no React and no server imports lib/server/Server-only code: db/,services/,api/,ai/,jobs/,mail/,storage/,auth.ts,env.ts,telemetry.ts,valkey.ts; the only folder that imports frombunserver/,schemas/,lib/ -
src/server/is fenced. The browser build denies the whole folder as well as the*.server.*suffix, so a server-only import that reaches the browser bundle fails the build, and warns in dev:apps/web/vite.config.ts tanstackStart({importProtection: {// A custom list replaces Start's default, so the suffix rule is restated.client: { files: ["**/*.server.*", "**/server/**"] },},}),The
.server.tssuffix is only for a server-only file that must live elsewhere, and.client.tsfor browser-only code the server must never load -
Handlers stay thin (ADR-0014). Business rules live in
server/services/<domain>.ts, as plain functions that take their dependencies as parameters. A server function infunctions/<domain>.tsand an API handler inserver/api/v1/<domain>.tsparse their input with the shared schema, check access and call the service -
API routes are server routes that only wire (ADR-0036).
routes/api/v1/<domain>.tsmaps methods to the handlers inserver/api/v1/<domain>.ts;server/api/http.tsholds what every handler shares —readJson,readQuery, and theApiHandlertype.routes/api/auth/$.tshands/api/auth/*to Better Auth (ADR-0009), and webhooks live inroutes/api/webhooks/. Start’s global request middleware — rate limiting first (ADR-0010) — runs before all of them:apps/web/src/routes/api/v1/invoices.ts import { createFileRoute } from "@tanstack/react-router";import { createInvoice, listInvoices } from "@/server/api/v1/invoices";export const Route = createFileRoute("/api/v1/invoices")({server: { handlers: { GET: listInvoices, POST: createInvoice } },}); -
The database lives in
server/db/(ADR-0008).client.tsis the only file that connects.schema/holds one file of tables per domain, plusauth.ts, which Better Auth’s CLI writes and nobody edits;schema/index.tsre-exports them all, andrelations.tsholds the singledefineRelations()call.drizzle.config.tsreadsschema/and writes migrations todrizzle/at the app root -
Environment variables are parsed once, with T3 Env.
server/env.tsdeclares every server variable withcreateEnvfrom@t3-oss/env-core, pinned to an exact version while pre-1.0, and exportsenv; no other file readsprocess.env. It readsprocess.env, notimport.meta.env, because Vite inlinesimport.meta.envat build time and one image runs everywhere (ADR-0016):apps/web/src/server/env.ts import { createEnv } from "@t3-oss/env-core";import * as z from "zod";export const env = createEnv({server: {APP_ORIGIN: z.url(),CLIENT_IP_HEADER: z.string().min(1),DATABASE_URL: z.url(),VALKEY_URL: z.url(),BETTER_AUTH_SECRET: z.string().min(32),},runtimeEnv: process.env,emptyStringAsUndefined: true,});A bad configuration fails the first request that loads
env. The/readyzprobe is that request, because it reaches Postgres and Valkey (ADR-0015), so the instance never takes traffic (ADR-0017). Public values the browser needs go inlib/env.ts, created only when there is one, withclientPrefix: "VITE_"andruntimeEnv: import.meta.env; they are baked into the image, so nothing that differs between environments belongs there — the server sends those through a loader..env.examplelists every variable and is committed;.envis git-ignored -
Names:
- Files and folders are kebab-case:
invoice-table.tsx,use-invoice-filters.ts. The shadcn/ui CLI writes the same - One component per file, named after it, as a named export:
invoice-table.tsxexportsInvoiceTable. No default exports, except where a tool requires one, such as a config file - Hooks are
use-<name>.tsand exportuse<Name> - Domain names are plural and match the URL:
/invoices,functions/invoices.ts, theinvoicestable - Suffixes are kept for what tools read:
.test.tsand.test.tsxrun withbun run test,.int.test.tswithbun run test:int(ADR-0014);.spec.tsmarks Playwright specs ine2e/, outside thesrc/folderbun testsearches;.server.tsand.client.tstrigger import protection;.gen.tsmarks a generated file. There are no role suffixes such as.service.ts: the folder already says the role
- Files and folders are kebab-case:
-
Routes use the router’s file syntax, as folders:
invoices/$invoiceId.tsx, notinvoices.$invoiceId.tsx. A pathless layout starts with_(_authed.tsxguards every route in_authed/), a(group)/folder organizes routes without adding a URL segment, and params are camelCase and say what they hold:$invoiceId, not$id. Nothing but route files lives inroutes/ -
Imports use the
@/alias for anything outside the current folder (@/components/ui/button), as shadcn/ui’scomponents.jsonsets it up; relative imports stay within one folder -
Pull requests are squash-merged, and the title is a Conventional Commits 1.0.0 message, because it becomes the one commit on
main:<type>(<scope>)!: <summary>, such asfeat(invoices): export to CSV- Types:
featandfixfor what users notice;perf,refactor,test,docs,style,build,ci,choreandrevertfor the rest. It is commitlint’s conventional set, which people and agents already know - The scope is optional: a domain (
invoices) or an app or package (web). Renovate usesdeps !marks a change someone outside the pull request must act on: a public API route removed or changed (ADR-0036), a new required environment variable Nodus must set before the deploy, or the contract step of a migration (ADR-0016)- Commits on a branch are free-form; the squash replaces them
- Squash is the only merge style the repository allows. On GitHub, the squash commit message is set to default to the pull request title; Forgejo uses the title already. Renovate is set to
semanticCommits: "enabled"rather than left to detect the convention, so its pull requests are titledchore(deps): …orfix(deps): …from the first one - One workflow step checks the title. It re-runs when the title is edited, is a required check on
mainwith the others (ADR-0016), and reads the title from an environment variable, because a title is untrusted input and must never be expanded inside the script:
.github/workflows/pr-title.yml name: pr-titleon:pull_request:types: [opened, edited, synchronize, reopened]permissions:contents: readjobs:title:runs-on: ubuntu-lateststeps:- name: Title is a Conventional Commitenv:TITLE: ${{ github.event.pull_request.title }}run: printf '%s\n' "$TITLE" | grep -Eq '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .+' - Types:
-
Product decisions live in the product:
docs/decisions/NNNN-short-slug.md, in this site’s ADR format. A deviation from these defaults, or a choice an ADR leaves to each product, is written there -
AGENTS.mdat the root, copied from the AGENTS.md template, names the commands, the stack and these rules, so people and coding agents start from the same map
The deciding factors: one map for every product, the paths earlier ADRs already chose, and a server boundary the build enforces rather than one reviewers must spot. We accept one domain spread across several folders, and workspace overhead in repositories that only ever hold one app.
Consequences
Section titled “Consequences”Easier:
- Moving between products: server functions, tables, API routes and tests are in the same place in every repository
- A server-only import that reaches the browser fails the build, instead of shipping a database client or a secret
- A second deployable or a shared package is a new folder, not a restructuring
- A missing or malformed environment variable stops an instance at its readiness check, with every problem listed, instead of failing deep inside a request
- Coding agents follow the same rules as people, written once in
AGENTS.md mainreads as a change log: one typed line per pull request, with!on whatever must be acted on before a deploy
Harder:
- One domain touches several folders, so adding or removing a feature means visiting each. Searching for the domain name finds them all, because every file carries it
- Workspace overhead in single-app repositories:
workspacesin the rootpackage.json,--filterin root scripts, and a Dockerfile whose build context is the repository root - The import-protection list restates Start’s default; a Start upgrade that changes the default must be checked against it
- T3 Env is pre-1.0: pinned exactly and upgraded deliberately, like the libraries in ADR-0023 to ADR-0025
- A pull request gets one type, so unrelated changes go in separate pull requests; a branch’s individual commits are not kept on
main
Given up: the locality of feature folders and the simplicity of a single package. Accepted — the same map in every repository, and a server boundary the build enforces, are worth a few more folders to open.

