A coding pattern for starting and shaping any TypeScript application — single app vs pnpm monorepo, package boundaries, folder structure, Drizzle/Postgres schema design, the backend route shape (auth wrapper, Zod parsing, services, response and error helpers), env handling, tooling (Biome or ESLint, Knip, tsconfig), tests, CI and an AGENTS.md rulebook. Use when initializing a repo, scaffolding a new project or app, adding a package or workspace, designing database tables, deciding where code should live, or before any coding task in a repo that has no written conventions yet.
install
npx skills add nabinkhair42/nk-skills --skill project-blueprint
Project Blueprint
A repeatable way to lay out and write a TypeScript application, whatever it does. The pattern optimizes for three things: every kind of code has one home, every input crosses one validated boundary, and every fact is written down once.
Per-feature CRUD layers (endpoint → Zod → route → service → hook → component) follow product-stack. This skill covers what surrounds them: repo shape, packages, database design, backend boundaries, env, tooling and the written rules.
Examples use neutral resources (projects, tasks) and the scope @app. Rename them to the domain you are building.
How the Code Reads
These habits apply to every file:
- The smallest complete change.
- Prefer deleting code to adding it.
- No options, wrappers or “for later” code nobody asked for.
- Reuse the helper that already exists before writing a new one.
- Thin edges, thick services. Routes and components only wire things together. Logic lives in server services and pure helper files.
- Small files with one responsibility. Kebab-case names. About 200–250 lines at most; split along a responsibility seam, not at a line count.
- Names over comments.
- No file-level docblocks and no comments that restate WHAT the code does.
- A one-line WHY comment only where a reader would otherwise trip.
- A known limitation gets one line saying the limit and when to fix it.
- Types come from their source. Use
z.inferfor inputs,$inferSelect/$inferInsertfor rows, and enum unions from the enum. Never hand-write a type that duplicates one of these. - One way to do each thing. One error shape, one HTTP client, one endpoints file, one env file. A second way is a bug waiting to happen.
- Invariants belong in the database, not in “check, then write” app code.
Step 0: Pick the Shape
| Situation | Shape |
|---|---|
| One web app and its UI is the only API consumer | Single app: product-stack layout, route handlers or Server Actions |
| A separate API, a background worker, a second UI, or a CLI sharing types | pnpm monorepo |
| More than about 3 deployables or slow builds | Monorepo + Turborepo (cached build / typecheck) |
| Few deployables and fast builds | Monorepo + pnpm -r / pnpm --filter + concurrently for dev |
Don’t start with a monorepo “for later”. Split when the second deployable appears.
Monorepo Layout
repo/
├── apps/
│ ├── web/ # UI only: no route handlers, no DB imports
│ ├── api/ # HTTP API
│ └── worker/ # background jobs (only if the product needs them)
├── packages/
│ ├── core/ # start here: db, schemas, server services, endpoints, lib
│ │ # split later into db / contracts / auth when two apps need different slices
│ └── ui/ # shared shadcn primitives, only once a second UI exists
├── docs/ # ARCHITECTURE.md, DEPLOY.md
├── AGENTS.md # the rulebook; CLAUDE.md is just `@AGENTS.md`
├── .env.example # ONE env file for every app
├── pnpm-workspace.yaml
└── package.json # dev / build / typecheck / test / check
Package rules
- Name every package
@<scope>/<name>, one scope per repo. - Only the db package imports the database driver. Everything else goes through it, so connection settings and transactions stay in one place.
- The web app never imports server code (db, server services, secrets). It reads shared schemas, types and endpoint constants only.
- Exports:
- Source-consumed packages:
"exports": { "./*": "./src/*.ts" }, no build step. - Build to
dist/only what a non-TypeScript runtime consumes.
- Source-consumed packages:
- Public import paths are a contract. Anything imported as
@scope/pkg/...must keep working after a refactor. - Pin shared versions once with the pnpm
catalog:. Everyoverridesentry gets a one-line WHY (usually a security floor). - Root
package.json:- Set
"packageManager"and"engines". - Provide one
checkscript (lint && typecheck && knip && test). CI runs exactly this.
- Set
Database (Drizzle + Postgres)
Full examples are in reference.md.
- Files:
schema/<domain>.ts, one domain per file, re-exported fromschema/index.ts. That barrel and package entry points are the only barrels. - Every owned row has an owner column. Use
user_idwhen users own data alone,org_id(orworkspace_id) when teams share it. Every query on an owned table filters on it. Take the owner id from the session, never from the request body. - Timestamps:
timestamp(..., { withTimezone: true }).notNull().defaultNow(). Usecreated_atalways, andupdated_aton anything editable. - Index every foreign key and every list query (
owner_id, created_at). Name indexes<table>_<cols>_idx; unique ones get_uidx. - Invariants are constraints. A rule like “one active subscription per user” or “one open ticket per customer” is a (partial) unique index. Then two racing requests cannot both win.
- Enums:
text("status", { enum: [...] })rather thanpgEnum. A new value needs no migration. - Foreign keys: reference with thunks (
.references(() => projects.id, { onDelete: "cascade" })), and always writeonDeleteexplicitly. - Soft delete only where history matters. Add a
deleted_atcolumn plus partial indexesWHERE deleted_at IS NULL. Everything else is deleted for real. - Types come from the schema (
$inferSelect/$inferInsert). - Migrations:
- Run
drizzle-kit generate, read the SQL diff, commit it, thenmigrate. - Use
pushonly on a throwaway dev or CI database. - Set
strict: true.
- Run
- Transactions: use a pooled driver that supports them for any multi-write that must be all-or-nothing. Plain serverless HTTP drivers usually don’t support them.
- Auth-library tables keep that library’s own naming. Your domain tables follow yours.
Decide once per repo and write it in AGENTS.md
| Decision | Default | Alternative |
|---|---|---|
| Primary key | uuid("id").primaryKey().defaultRandom() |
text("id") + app-generated id when the id must exist before the insert |
| Table names | plural snake_case (projects) |
singular (project) — never mix in one repo |
| TS property names | camelCase props → snake_case columns (ownerId: uuid("owner_id")) |
snake_case props that mirror SQL |
relations() |
none; explicit joins | relational queries where nesting reads clearer |
Backend Boundaries
Pick the HTTP layer by size:
- Framework route handlers (e.g. Next.js): one app (
product-stack). - A small router with file routes (e.g. Hono):
- A separate API that should stay lean.
- Each route is
routes/<path>/route.tsexportingGET/POST/…;[id]maps to:id.
- A DI framework (e.g. Nest):
- Many guards, permissions, API keys or rate limits per route.
- One module, controller and service per domain, with pure helper files beside them.
Every route has the same shape, whatever the framework:
auth wrapper/guard → parse input with a shared Zod schema → call a server service → return through response helpers
- Validation: Zod at every boundary: HTTP body, query string, queue payload, webhook and env. Schemas are named
createProjectSchema, withtype CreateProjectInput = z.infer<...>. Parse with one helper (safeParse→Errors.badRequest, orparseBody(schema, body)). - One error shape for the whole API, e.g.
{ success: false, error, details? }, or{ code, message, statusCode }with snake_casecodes mapped to messages in one file. Return or throw through helpers only, never a rawResponse.json. - One success shape for the whole API, e.g.
{ success: true, data }, or the raw object, or{ items }for lists. Lists that can grow get keyset pagination (next_cursor), not offset. - Auth: one wrapper (
withApiSession/protectedApi/ a guard) runs before the handler. Public routes go on a short, explicit allowlist. - Services hold the logic and DB access, return data, and never touch HTTP or the UI.
- Background work goes to a durable queue or workflow engine, never a
void promiseinside a request. Start with a Postgres-backed queue; add more infrastructure only when Postgres is proven insufficient. - Versioning: version APIs that outside clients call (
/api/v1/...). An API only your own UI calls can stay unversioned.
Client Data Layer
config/api-endpoints.tsholdsAPI_ENDPOINTS(UPPER keys, functions for ids) andQUERY_KEYS. Never write a path string anywhere else.- One HTTP client instance with credentials and a 401 redirect. Services are object literals (
projectsService.list()) that unwrap the response and do nothing else. - Hooks (React Query) own the cache and the toasts. Components call hooks, never services.
- Server components by default. Use client-side fetching only where data must stay live. Build on shadcn/ui primitives.
Env
- One env file. Keep
.env.local+.env.exampleat the repo root for every app, never per-app copies. Each app loads the root file explicitly. - Validate at boot with a Zod schema that reports every bad variable in one error.
- Treat
""as unset (||, not??), so an empty optional key falls back to its default. - List every key in
.env.example, optional ones left empty, with no comments. - No duplicates: one key per fact, never two names holding the same value.
- One secret per job. Session signing and data-at-rest encryption use separate secrets, so either can rotate without breaking the other.
- Generate secrets with
openssl rand -base64 32.
Tooling
- Lint and format:
- Biome (fast, one tool), or ESLint flat config +
unused-importswhen you need custom rules such asmax-lines. - Exclude vendored folders from both.
- Biome (fast, one tool), or ESLint flat config +
- Knip catches dead files, exports and dependencies. Delete what it finds; don’t silence it.
- tsconfig:
- Turn on
strictandnoUncheckedIndexedAccess. - Node packages use either NodeNext with relative
.jsimports, or bundler resolution with@scope/pkg/...subpaths. Pick one style.
- Turn on
- Vendored UI (shadcn and other registries) is added with its CLI and never hand-edited. Once you change a vendored piece, it is yours: move it out of the vendored folder and drop any duplicate primitives it carried.
Tests and CI
- Location: tests live in
<pkg>/test/, never insrc/. - Runner: Node’s built-in runner (
node --import tsx --test 'test/**/*.test.ts'); Vitest where you need its mocking. - What to test:
- New non-trivial logic gets at least one test: branches, parsers, money, security checks.
- Trivial wiring needs none.
- Database: integration tests run against a throwaway Postgres (Docker or a CI service), never a shared or hosted database.
- CI:
pnpm install --frozen-lockfile→pnpm check. Add an image build per deployable if you ship containers, plus a dependency vulnerability scan.
AGENTS.md
Every repo gets one, and it wins on conflict. It holds:
- Package manager and task runner.
- Package map and import rules (who may import the db driver, what the web app may import).
- The per-repo decisions from the table above (ids, naming, owner column, response shape).
- Boundary rules (“Zod at every boundary”, “owner id from the session”).
- Code style (file size, comments, no new barrels).
- What is out of scope for this version.
- Invariants that code review must enforce.
Files under docs/ explain how things work; they never restate AGENTS.md rules. CLAUDE.md contains only @AGENTS.md.
New Repo Checklist
- Pick the shape (Step 0) and the per-repo decisions; write them into
AGENTS.md. - Run
pnpm init, then addpnpm-workspace.yaml,packageManager,engines, and thecatalog:. - Create the db package (or
core): client,schema/index.ts, auth tables,drizzle.config.ts(strict: true). - Add the shared Zod schemas and the env schema.
- Create the API: auth wrapper, response/error helpers, input parsing helper,
/health. - Create the web app:
config/api-endpoints.ts, HTTP client, query client, providers,shadcn init. - Add root
.env.examplewith every key, and.env.localwith generated secrets. - Add tooling: Biome/ESLint, Knip, a base tsconfig, and the
checkscript. - Add CI running
pnpm check. Add Dockerfile/compose only once you deploy containers. - Write
README.md(run it in three commands) anddocs/ARCHITECTURE.md(layout + layers).
Common Mistakes
- A monorepo on day one for a single web app.
- The DB driver imported outside the db package.
- Owner id read from the request body instead of the session.
- Invariants enforced only in code (“check, then insert”) instead of a unique or partial index.
pgEnumfor values that will grow; every new value costs a migration.- Missing foreign key indexes. Drizzle doesn’t create them.
db:pushagainst production. Generate, review, migrate.- Per-app env files, or two keys holding the same value.
- Two error shapes in one API.
- Fire-and-forget promises for work that must finish.
- Rules scattered across README, docs and comments instead of one AGENTS.md.
- Abstractions, options or config built “for later”. Add them when the second caller exists.