You bought a foundation. This document is how you build on it.
ARCHITECTURE.md gives the rules and CODE_STANDARDS.md the conventions. Neither walks you through one feature end to end, and that is what this does: one small feature, from an empty schema line to a working control, naming the real file at every step so you can diff your work against something that already exists and passes CI.
Read it once with the repository open. After that, the eight-step list at the bottom is the whole thing.
The bar this document is held to: somebody who has never seen this codebase follows it and adds a field, a service method, its authorization test, and a control — without asking a question. If you had to ask, that is a bug in this file. Please say so.
The feature we are going to build
Pin a project. An owner or admin marks a project as pinned; pinned projects sort to the top of the sidebar and the project list.
It is deliberately small, and deliberately touches every layer: a column, a migration, a permission, a service method, an authorization test, a router procedure, a page, and a component with its states. Everything larger is this, more times.
Before you write anything: the three rules that are not negotiable
They are enforced by ESLint, so you will find out at pnpm lint rather than in review — but knowing them first saves an hour.
- The tenant comes from the session, never from the URL.
ctx.orgIdis resolved once, in. Asrc/trpc/init.tsprojectIdin an input is a filter; it is never proof of anything. findUniqueis banned insidesrc/server/services/. UsefindFirstwithorganizationIdin thewhere. A lookup by id alone returns another organization's row, and that was the single largest defect class in v1.- Every file under
src/server/starts withimport "server-only";. It throws at build time if the module is ever pulled into a client bundle, so a leaked database call becomes a build failure instead of a shipped credential.
There is a fourth that no lint rule catches and that gates merge: every service ships an .authz.test.ts proving a member of organization B gets NOT_FOUND for an organization A resource — on read and on write. Step 4 writes one.
Step 1 — Schema
Open and find prisma/schema.prismamodel Project. Add the column:
model Project {
// …existing fields…
pinnedAt DateTime? @map("pinned_at") @db.Timestamptz(3)
@@index([organizationId, pinnedAt])
@@map("project")
}
Four conventions are in play, and all four are decided in ARCHITECTURE.md rather than per model:
| What | Why |
|---|---|
@db.Timestamptz(3) |
Prisma's Postgres default is timestamp(3) — no timezone. Every DateTime in this schema carries this. No exceptions. |
@map("pinned_at") |
Buyers open psql. Prisma's default emits quoted mixed-case identifiers that must be quoted in every hand-written query. |
| A nullable timestamp | Not a boolean. "When was it pinned" answers "is it pinned" and orders the pins, for one column and one index. |
Index leads on organizationId |
Every query filters by tenant first. An index that does not lead with it cannot serve the query. |
For a new model rather than a new column, @default(uuid(7)) on the id — v7 is time-ordered and preserves insert locality; v4 fragments the B-tree. Better Auth's own models keep their own generator; leave those alone.
Then regenerate the client:
pnpm db:generate
Reference: is the smallest complete tenant-owned model in the schema — id, model LabelorganizationId with its cascade, a @@unique scoped to the tenant, both timestamps, @@map. Copy its shape.
Step 2 — Migration
pnpm exec prisma migrate dev --name project_pinning
Then read the SQL it wrote, in prisma/migrations/<timestamp>_project_pinning/migration.sql. This is not ceremony:
db pushcannot create partial indexes orCHECKconstraints, so several already in this schema exist only inside migration files. Never usedb pushagainst a database that matters — it is for local iteration and nothing else.- Prisma's generator has a known habit in this repository of emitting statements nobody asked for. It cannot express the
GENERATED ALWAYStsvector columns that power search, so it reads them as ordinary and un-indexed and tries to make the database agree — emittingDROP INDEXon both search GIN indexes andALTER COLUMN search_vector DROP DEFAULT. Delete those lines. Running them reduces every search to a sequential scan, and theDROP DEFAULTerrors outright against a generated column, half-applying the migration.prisma/migrations/20260828015023_attachments_and_billing/migration.sqlcarries a note at the top saying exactly this. Expect it every time.
If your change needs something Prisma cannot express — a partial index, a CHECK — hand-write it into the migration file:
-- Pinned projects only. The board query filters on `pinned_at IS NOT NULL`,
-- and a partial index over the handful of pinned rows is a fraction of the
-- size of one over every project.
CREATE INDEX "project_pinned_idx" ON "project" ("organization_id", "pinned_at" DESC)
WHERE "pinned_at" IS NOT NULL;
Commit the migration file. Buyers run migrations against real data.
Step 3 — Permission
Only if the feature needs a new one. Pinning does: it is not "update the project", because we want a member to be able to edit a project without rearranging everyone's sidebar.
Open . Add the statement, then add it to each role that should hold it:src/lib/permissions.ts
// in the statements object
project: ["create", "update", "delete", "archive", "pin"],
// owner and admin hold it; member and viewer do not.
// A pin is organization-wide furniture — one person's pin is on everyone's
// sidebar — which is the same argument that gives `member` label:create but
// not label:update.
Two things follow automatically, and they are the reason this file exists:
useCan()in a component andpermittedProcedureon the server read the same definition, so a control the interface offers is always a control the server allows.pins the whole matrix. Adding a statement fails that test until you add the expectation, which is deliberate — wideningsrc/lib/permissions.test.tsviewerby accident should break the build.
A hardcoded role === "owner" in a component bypasses all of this and is a review rejection.
Step 4 — Service, and its authorization test
Write the test first. Not for TDD points: writing it first is what stops you writing a service that reads by id and only later wondering which organization it belonged to.
The service
Create or extend src/server/services/project.service.ts. Services are pure functions of (ctx, input) — that is the whole reason authorization is unit-testable here.
import "server-only";
import { TRPCError } from "@trpc/server";
import { ActivityType, recordActivity } from "@/lib/activity";
import type { OrgContext } from "@/lib/authz";
import db from "@/lib/db";
export async function setPinned(
ctx: OrgContext,
input: { projectId: string; pinned: boolean },
) {
// findFirst, with the tenant in the where. Never findUnique — a foreign id
// must be indistinguishable from a missing one.
const project = await db.project.findFirst({
where: { id: input.projectId, organizationId: ctx.orgId },
select: { id: true, name: true, pinnedAt: true },
});
// NOT_FOUND, not FORBIDDEN. FORBIDDEN confirms the row exists and leaks the
// id space. See docs/ARCHITECTURE.md, "Errors".
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const updated = await db.project.update({
where: { id: project.id },
data: { pinnedAt: input.pinned ? new Date() : null },
select: { id: true, name: true, pinnedAt: true },
});
// `recordActivity` takes one object and no transaction client, and it
// deliberately never throws — an audit write failing must not take down the
// action that triggered it. See the note in src/lib/activity.ts.
//
// `projectId` is a column, not only payload: the project feed filters on
// the column, so a row carrying it just in the JSON is invisible there.
await recordActivity({
type: input.pinned
? ActivityType.PROJECT_PINNED
: ActivityType.PROJECT_UNPINNED,
organizationId: ctx.orgId,
actorId: ctx.userId,
projectId: project.id,
payload: { name: project.name },
});
return updated;
}
PROJECT_PINNEDandPROJECT_UNPINNEDdo not exist yet — add them toActivityTypein, besidesrc/lib/activity-types.tsPROJECT_ARCHIVED. Every other identifier above is real, andrecordActivity's single-object signature is the one in the codebase; it does not take a transaction client.
When two writes must not come apart, use db.$transaction. Pinning does not need one — recordActivity never throws, so there is nothing to roll back. Creating a comment and its notifications does: see , which returns both from one interactive transaction.comment.service.ts
What a service may and may not do:
| May | May not |
|---|---|
Import lib/** and generated/prisma |
Import next/headers — lint-enforced |
| Hold every business rule | Import @/lib/auth or call getSession() — lint-enforced |
Throw TRPCError with the right code |
Import anything from components/ or app/ |
Take (ctx, input) and nothing else |
Read the request in any form |
The rule behind the right-hand column: a service that reads the session is callable from exactly one place. This one is callable from a test, a seed script, a cron job, and a CLI.
One concept per file. Past roughly 300 lines a service is doing more than one thing; split it by the resource it owns.
The authorization test
src/server/services/project.authz.test.ts. This is the release gate. Read as the model — it is short and it proves the right things.label.authz.test.ts
import { expect, it } from "vitest";
import * as projectService from "@/server/services/project.service";
import { describeDb } from "@/test/db";
import { makeOrgWithOwner, makeProjectWithBoard } from "@/test/factories";
function expectNotFound(promise: Promise<unknown>) {
return expect(promise).rejects.toMatchObject({ code: "NOT_FOUND" });
}
describeDb("project.service pinning authorization", () => {
it("refuses to pin another organization's project", async () => {
const a = await makeOrgWithOwner();
const b = await makeOrgWithOwner();
const { project } = await makeProjectWithBoard({
organizationId: a.organization.id,
});
await expectNotFound(
projectService.setPinned(b.ctx, { projectId: project.id, pinned: true }),
);
// And prove nothing happened — a NOT_FOUND thrown *after* the write is
// still a write. This is the assertion people leave out.
const untouched = await projectService.byId(a.ctx, {
projectId: project.id,
});
expect(untouched.pinnedAt).toBeNull();
});
});
Three things about that test worth copying:
describeDb, notdescribe. It truncates between tests and skips the whole suite by name whenDATABASE_URL_TESTis unset, so a machine without a test database gets a clean skip rather than a red run. See.src/test/db.ts- Factories, not fixtures.
hassrc/test/factories.tsmakeOrgWithOwner,makeMemberIn,makeProjectWithBoard,makeTask,makeLabel, and more. Each test creates what it needs; nothing is shared and mutable. - Assert the absence of the write, not only the error. Half the value of these suites is in the second assertion.
Run it:
pnpm vitest run src/server/services/project.authz.test.ts
Step 5 — Router
src/trpc/routers/project.router.ts. A procedure does exactly four things, in this order: pick the rung, validate input, check a permission, call one service function.
export const setPinnedInput = z.object({
projectId: z.string().min(1),
pinned: z.boolean(),
});
// in the router object
setPinned: permittedProcedure({ project: ["pin"] })
.input(setPinnedInput)
.mutation(({ ctx, input }) => projectService.setPinned(ctx, input)),
Choosing the rung
Pick the highest rung that matches what you touch, and never re-derive a guarantee a lower one already gives you.
| Procedure | Proves | Adds to ctx |
|---|---|---|
baseProcedure |
nothing | — |
protectedProcedure |
a valid session | userId, session |
orgProcedure |
an active organization, caller is a member | orgId, role, member |
projectProcedure |
the project belongs to orgId, visibility passes |
project |
taskProcedure |
the task's project belongs to orgId |
task, project |
permittedProcedure({ … }) is orgProcedure with a permission check applied; permittedTaskProcedure is the same at the task rung. They are procedure factories rather than .use(requirePermission(…)) because a standalone tRPC middleware is typed against the root context and cannot see ctx.role without a cast.
Using
orgProcedureand then loading the project by hand duplicates a check, and duplicated checks drift. If you find yourself doing that, you wantedprojectProcedure.
Then register the router in if it is new.src/trpc/routers/_app.ts
A router may not import @/lib/db — lint-enforced. If a procedure body has an if in it, that if belongs in the service.
Reference: is 70 lines and shows all of it, including the one interesting case — label.router.tssetForTask hangs off the task rung and task:update rather than a label statement, because tagging a task is editing that task.
Step 6 — Component
Components live under src/components/<feature>/. Server Components by default; add "use client" only when the file needs state, effects, or handlers, and push the directive as far down the tree as it goes — a client boundary at the page level ships the whole page to the browser.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Pin, PinOff } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useCan } from "@/hooks/use-can";
import { useTRPC } from "@/trpc/client";
export function PinProjectButton({
projectId,
pinned,
}: {
projectId: string;
pinned: boolean;
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
// Rendering only. The server checked the same statement independently and is
// the only thing that actually allows the action.
const canPin = useCan({ project: ["pin"] });
const setPinned = useMutation(
trpc.project.setPinned.mutationOptions({
onSuccess: () => queryClient.invalidateQueries(trpc.project.pathFilter()),
onError: (error) => toast.error(error.message),
}),
);
// The permission-denied state is absence, not a disabled button: a control
// you can see and cannot use is a question you have to answer in support.
if (!canPin) return null;
return (
<Button
variant="ghost"
size="icon"
aria-label={pinned ? "Unpin project" : "Pin project"}
disabled={setPinned.isPending}
onClick={() => setPinned.mutate({ projectId, pinned: !pinned })}
>
{pinned ? <PinOff aria-hidden /> : <Pin aria-hidden />}
</Button>
);
}
The five states, every time
A surface is not finished until it handles empty, loading, error, success, and permission-denied. A component that only handles the happy path is not done. Counters and rollups additionally handle zero — v1 rendered NaN charts by dividing by a zero total.
Accessibility is part of the same change
Keyboard reachable, visible focus ring, labelled controls, contrast met, prefers-reduced-motion respected, and a working layout at 320px. Not a later audit. Note the aria-label on the icon-only button above, and that its icon is aria-hidden because the label already says it.
Do not hand-edit src/components/ui/
Those are generated shadcn primitives. Need a variant? Add it through the component's cva config, or wrap it in a feature component.
Step 7 — Prefetch
Data is fetched once, on the server; the client subscribes. A page that renders a spinner while the browser makes a round trip the server could have made is a defect, not a loading state.
// src/app/(app)/[orgSlug]/projects/page.tsx — Server Component
import { Suspense } from "react";
import { HydrateClient, prefetch, trpc } from "@/trpc/server";
// Required on every prefetching page. See below.
export const dynamic = "force-dynamic";
export default async function ProjectsPage() {
prefetch(trpc.project.list.queryOptions({}));
return (
<HydrateClient>
<Suspense fallback={<ProjectListSkeleton />}>
<ProjectList />
</Suspense>
</HydrateClient>
);
}
// the client component
const { data } = useSuspenseQuery(trpc.project.list.queryOptions({}));
The rules that bite, in the order they will bite you:
- The prefetch input and the
useQueryinput must be identical. A different input is a different query key, the hydrated data is ignored, and the page silently refetches. This pattern's one failure mode, and it fails quietly. - Never
awaitprefetch. It is fire-and-forget;HydrateClientdehydrates whatever resolved. Awaiting serializes your prefetches. - Subscribe with
useSuspenseQuery, notuseQuery, and give it a<Suspense>boundary. The query client dehydrates pending queries, so a plainuseQueryrenders its loading branch on the server while the client hydrates with data that already arrived — a hydration mismatch React only reports in development. export const dynamic = "force-dynamic"is not optional on a prefetching page.createTRPCContextreadsheaders()inside a promise that escapes the render pass, so Next cannot attribute the dynamic access in time, the route stays a static-prerender candidate, and the build fails with a redactedError: redactednaming no cause and no file. If a build fails that way, this is why.- Loading is the
<Suspense fallback>; the error state is the segment'serror.tsx. A component that suspends must not also carry anisPendingbranch — it is unreachable and it drifts. - Refresh RSC-prefetched data with
router.refresh(), notinvalidateQueries. Invalidating auseSuspenseQueryre-suspends its boundary, which unmounts the component holding the observer, which cancels the refetch the invalidation just started.invalidateQueriesstays correct for queries the client owns outright. - Format dates through
.src/lib/format.tstoLocaleDateString(undefined, …)uses the runtime's locale and timezone, which differ between server and browser; the mismatch makes React discard the tree.
Working reference: plus src/app/(marketing)/status/page.tsx is the smallest complete example in the repository. Copy that shape.src/components/system/status-card.tsx
Step 8 — Verify
pnpm lint && pnpm typecheck && pnpm test && pnpm build
All four run in CI on every push and pull request, against a Postgres service container — no account and no API key, so a fork runs green on the first try.
If you touched anything a user clicks, also run the browser suite. It is the only gate that reads the markup, and it is the only one that catches a panel that moved behind a tab:
pnpm test:e2e
That is not hypothetical. A UI pass in this repository moved the task details panel behind tabs, ran the other four gates, and shipped two broken specs to main for a day. The rule it earned: a change that moves UI is not exempt from pnpm test:e2e.
The eight steps, as a checklist
- Schema — model or column in
prisma/schema.prisma, withorganizationIdand an index leading on it.pnpm db:generate. - Migration —
prisma migrate dev, then read the SQL. Hand-write anything Prisma cannot express. Commit it. - Permission — a statement in
src/lib/permissions.ts, if the feature needs one. - Service + authz test —
(ctx, input), every query filtered byctx.orgId,findFirstneverfindUnique. Write the test first. - Router — the highest matching rung, Zod input, permission, one service call. Register it in
_app.ts. - Component — under
src/components/<feature>/, gated withuseCan(), all five states, accessible, 320px. - Prefetch — the RSC calls
prefetch(...)with the same input, wrapped in<HydrateClient>,dynamic = "force-dynamic". - Verify — four gates, plus
pnpm test:e2eif you moved any UI.
Things that will trip you up
Collected because each of these cost somebody an hour in this repository.
| Symptom | Cause |
|---|---|
Build fails with Error: redacted and Export encountered an error on /… |
A prefetching page without export const dynamic = "force-dynamic". |
| The page shows a spinner, then the data — but the server already had it | The prefetch input and the useQuery input differ. Different key, silent refetch. |
A useSuspenseQuery never updates after a mutation |
invalidateQueries on a suspended query deadlocks. Use router.refresh(). |
| Hydration mismatch on a date | toLocaleDateString with the runtime locale. Use src/lib/format.ts. |
| Search stops using its index after an unrelated migration | Prisma emitted DROP INDEX on the tsvector GIN indexes. Delete those lines from the migration. |
prisma migrate dev hangs after applying |
Known here. It applies and then sits. migrate deploy never prompts. Kill orphaned processes — they hold connections. |
| A database suite fails on fixtures it just created | An orphaned migrate dev or a killed Vitest still holding connections to the test database and truncating under you. |
server-only throws in a script |
It resolves to a throwing module outside a React Server Component. A script cannot import anything that carries it — which is why src/lib/demo-workspace.ts takes its Prisma client as an argument. |
useCan() says yes but the server says no |
You are reading the right file — the server's answer is authoritative and useCan is static and for rendering only. |
A viewer can do the thing |
You checked useCan() and forgot permittedProcedure. The client check is decoration. |
Where to look when this document runs out
| Question | File |
|---|---|
| Why is it built this way? | ARCHITECTURE.md — normative, and says what is enforced |
| How do I name / format / test this? | CODE_STANDARDS.md |
| What was deliberately left out? | ROADMAP.md — what is coming, and what never is |
| What does this provider need? | SETUP.md |
The codebase was written against a set of published agent skills — Better Auth, Postgres, and the UI system. They belong to their authors rather than to us, so they are not redistributed here; skills-lock.json is committed and names each one — its upstream repository, its path, and a hash of the pinned revision — so the exact set can be re-fetched into .agents/skills/ with the skills CLI. pnpm dlx @tanstack/intent@latest list shows the tRPC and TanStack ones. Load the relevant one before a substantial change; the table in CODE_STANDARDS.md says which.
Use
pnpm dlx, nevernpx. npm's arborist crashes on pnpm's symlinkednode_modulesin this repository.