Skip to content
Chapter 101Lesson 2

The thin README and source-as-doc

Keep the README to its one first-contact job and let your source files document everything else, the source-as-doc discipline that keeps a project's docs from drifting.

You have read a README like this before. The first fifty lines are clean: what the project is, how to get it running. Then somewhere around line 80 it falls apart. The env-var table is missing three variables, the “Data model” section describes columns that were renamed two quarters ago, and the “Architecture” heading hasn’t been touched since the first commit. Nobody reads past line 50, and everything below it is quietly out of date.

The fix is structure rather than discipline. In the last lesson you learned to name the four jobs a doc can do, and you picked up one reflex: before paraphrasing canonical truth, ask could this be a link? This lesson turns that reflex into the first artifact on your map. The goal is to make the README do exactly one job and let your source files document everything else. By the end you’ll be able to audit any README in about a minute and know precisely where every line that doesn’t belong should go instead.

A README has exactly two readers. The first is a new contributor in their first hour, someone who just cloned the repo and wants a dev server running before lunch. The second is a recruiter or reviewer skimming the project on GitHub, deciding in thirty seconds whether this codebase looks like it was built by someone who knows what they’re doing. Those are the only two, and they have something in common: both want the same small handful of things, and nothing else.

That handful defines the README’s job, which is first contact. Anything a reader needs after the first hour is, by definition, not the README’s problem. It belongs in the file that owns that truth.

This is why a thin README is a feature rather than a compromise. A short README earns trust, because there’s nowhere for an error to hide. A long one is presumed stale on sight, and it usually is. Hold onto the quality bar from the last lesson: a tutorial works if a new hire reaches a green checkmark in under thirty minutes without asking anyone. The README’s “Getting started” section is that tutorial, and it’s the only one the repo gets.

Once you accept that the README has one job and two readers, the structure isn’t a creative decision; it falls out on its own. There are five sections, each one short enough to fit on a single screen. Here is the whole template for our running example, a multi-tenant invoice SaaS, and then we’ll walk through why each section earns its place.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Title and one-paragraph description: one sentence on what the project is, one on the stack. That’s the entire section. A recruiter reads it in three seconds and a new hire knows what they cloned. Resist the urge to add a second paragraph, because there’s nothing a second paragraph would say that this one didn’t.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Getting started: the minimum copy-paste path to a running dev server. Put each command on its own line, with no prose between the commands. Narration is what makes a reader doubt the steps, whereas a bare command list reads as “this was tested and it works.” This block is the only tutorial in the repo.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Common tasks: the four or five things a daily contributor actually reaches for, with their commands. This isn’t an exhaustive catalog of every script in package.json. Include the tasks that come up most often, nothing aspirational.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Where the docs live: links, not duplication. This is the reflex from the last lesson made literal. Four pointers cover conventions, decisions, the data model, and the env vars. This section exists so that the rest of the README doesn’t have to carry any of it.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

License: one line. A recruiter checks it; a new hire never thinks about it. One line is the right amount of attention to give it.

1 / 1

Look at what every section has in common. Each one is either a path to running the project (clone, install, the command list) or a pointer to where the real content lives (the docs-live links). Not one section is a body of reference. The README is a map, not the territory. The moment a section starts describing something in detail instead of pointing at it, that section has wandered out of the README’s job.

One detail is worth flagging now, because we’ll come back to it later: the cp .env.example .env.local line in Getting started. That command quietly assumes a committed .env.example file exists. We’ll build that file when we get to the env-var documentation, and you’ll see how three separate sections close into a single loop.

Knowing what goes in the README is half the skill. The other half, the one that actually keeps it thin, is knowing what to evict and where each evicted thing lives instead. This is the part people get wrong. They feel the pull to be thorough, add a section, and a year later it’s the stale part nobody trusts.

So the rule isn’t “don’t add things.” It’s that every line which isn’t first-contact dilutes the README’s one job, and every one of those lines has a real home. The skill is routing each candidate to its owner:

  • Full API reference goes to the source. The function signature is the contract, and its TSDoc is the description. (More on this below, and the syntax in the next chapter.)
  • Full env-var list with descriptions goes to env.ts plus .env.example. We build this out two sections from now.
  • Contribution guidelines go to AGENTS.md, the conventions file the next lesson builds, or to CONTRIBUTING.md in the one case that warrants it (the last section of this lesson).
  • A “Philosophy” or “Architecture” section goes to /docs/adr/, the decision log. Watch for this one: an “Architecture” heading in a README is explanation leaking into a first-contact document. It’s exactly the mixing trap from the last lesson, wearing a respectable-sounding heading.
  • Changelog goes to CHANGELOG.md or your platform’s release notes.
  • Team conventions go to AGENTS.md.
  • Deployment notes go to /docs/how-to/deploy.md or a runbook.
  • TODOs, roadmap, meeting notes go to the issue tracker. These fail the audience test entirely: neither the recruiter nor the new hire ever wants them, so they don’t belong in a file written for those two readers.
  • Screenshots beyond one or two for context get cut. The UI changes, screenshots don’t, and nobody remembers to update an image.

A quick word on badges, since the question naturally lands here. They sit at the very top, in the recruiter’s line of sight. A CI-status badge and a license badge earn their place, because both answer an at-a-glance question a reviewer actually has: is it green, and can I use it? Everything else, like coverage percentages, dependency versions, or last-commit date, is noise dressed up as signal. The rule is blunt: zero or two badges, never a wall of them.

Now route the lines yourself. The drill below gives you a stack of candidate items; drop each into the artifact that should own it. The skill being tested isn’t what type of doc is this, which was the last lesson, but which file in the repo is responsible for this fact.

Each line below was found in a real README. Route it to the artifact that should own it — not what type of doc it is, but which file in the repo is responsible for the fact. Drag each item into the bucket it belongs to, then press Check.

Stays in the README First-contact only
A source file schema.ts / env.ts / the action
AGENTS.md or /docs/adr/ Conventions or decisions
Issue tracker / changelog Not for the README's readers
A one-paragraph description of what the project is
The local setup commands: clone, install, run
The test command
The license line
The invoices table’s column list
DATABASE_URL and what it’s for
How to rotate the Stripe webhook secret
Why we chose Drizzle over Prisma
A “Roadmap” section listing next-quarter features
A “Recent changes” list of the last few releases

Here is the idea the rest of this lesson rests on. We’ll state it plainly first, then get to why it works, since the reason is the whole point.

The rule: the file that owns a truth is the documentation for that truth. The schema file is the data-model doc. The env.ts file is the env-var doc. The Server Action, with its Zod input schema and its TSDoc, is the API doc for that action. The README links to these. It never copies them.

That sounds like a stylistic preference until you look at the mechanism underneath it. The key point is this: a paraphrase has nothing keeping it in sync with the thing it paraphrases. The instant the code changes, the paraphrase is silently wrong. No test fails, and no build breaks. The doc just starts saying something false, and it keeps saying it until someone notices, which for anything past line 50 is never.

The source-as-doc can’t drift that way, for a simple reason: updating the doc is the act of changing the code. When you add a column you edit the schema file, and the schema file is the data-model doc, so the doc updated itself. The developer was going to touch that file anyway. The documentation rode along for free.

This is the same argument the last lesson made for links between docs, taken one step further. There, a link stayed correct because it pointed at the source of truth instead of copying it. Here, the doc and the code are the same bytes, so there’s nothing to keep in sync because there’s only one thing. The diagram below makes that concrete: watch what happens to each side when the schema gains a column.

The schema gains a column. The paraphrase silently falls behind; the link has nothing to fall behind on.

Before the worked examples, one contrast is worth naming, because it’s a genuine advantage of this stack rather than an accident. A REST or tRPC codebase typically generates a separate API-reference document, and that document is one more thing that can fall out of sync with the handlers it describes. Our project never generates one. Because Server Actions are the API surface, the function signature already is the contract, so there’s no second artifact to keep aligned. That isn’t laziness; it’s the architecture removing a class of drift before it can start.

Now let’s make all of this concrete, three times over.

Here’s a Drizzle table for the running example. Read it the way a new contributor would, top to bottom, scanning for the shape of the data.

// The invoices table — one row per issued invoice; tenant-scoped on
// organization_id; status transitions are append-only via invoice_events.
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
amountCents: integer('amount_cents').notNull(),
status: text('status').notNull().default('draft'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index('idx_invoices_org_status').on(t.organizationId, t.status)],
);

A reader scrolling this file already sees every column, every constraint, every foreign key, and the index that makes the tenant-scoped query fast. That is reference documentation, and because it’s executable code the project actually runs, it cannot quietly go out of date. A wrong column here breaks the build, not just a paragraph.

// The invoices table — one row per issued invoice; tenant-scoped on
// organization_id; status transitions are append-only via invoice_events.
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
amountCents: integer('amount_cents').notNull(),
status: text('status').notNull().default('draft'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index('idx_invoices_org_status').on(t.organizationId, t.status)],
);

This one-paragraph header comment is the one addition worth making. The columns tell you what the table is; the comment tells you why it exists and names the one non-obvious invariant: status changes are append-only, recorded elsewhere. It adds the explanation in the one place it can’t drift, because it lives inches from the code it describes, in the same file you edit to change that code.

// The invoices table — one row per issued invoice; tenant-scoped on
// organization_id; status transitions are append-only via invoice_events.
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
amountCents: integer('amount_cents').notNull(),
status: text('status').notNull().default('draft'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index('idx_invoices_org_status').on(t.organizationId, t.status)],
);

Notice there’s no comment on this column, and there shouldn’t be. The name and type already say everything. The rule is one header comment per table and no per-column narration, unless a column’s purpose genuinely isn’t obvious from its name and type. A comment that restates amount_cents: integer is noise that will one day contradict the code.

1 / 1

The discipline is small enough to state in one line: one header comment per table, and no per-column comments unless a column would otherwise be a mystery. Resist the urge to annotate amountCents with // the amount, in cents. The type already says it and the name already says it, so the only thing that comment can do over time is become wrong while the code stays right.

The word tenant-scoped in that header comment carries some weight, so it’s worth unpacking. It means every row belongs to exactly one organization, and every query for invoices filters on organization_id so one tenant can never see another’s data. The whole app has worked this way for a while; the comment just makes the invariant visible to someone reading the schema cold.

Environment variables are the classic place documentation rots. The temptation is to keep a table of them in the README, maybe a second copy in an ENVIRONMENT.md, while the real list lives in the code. That leaves three sources of truth, at least two of them wrong at any given moment.

The course’s answer is one source: a typed env.ts built on @t3-oss/env-nextjs and Zod. The two files below are the entire env-var documentation surface. The first defines and validates the variables; the second is the committed example that a new contributor copies. Read them as two halves of one contract.

export const env = createEnv({
server: {
DATABASE_URL: z.url(),
STRIPE_SECRET_KEY: z.string().min(1),
// Webhook signing secret — without it, every Stripe event is rejected.
STRIPE_WEBHOOK_SECRET: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
},
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});

The validator, and the doc. Each variable’s Zod type is its reference. z.url() says “must be a URL,” and the server/client split says where it’s allowed to be read. This runs at build time, so a missing DATABASE_URL fails pnpm build. That’s stronger than any doc, because the contract is enforced by the toolchain rather than by a paragraph someone has to remember to keep accurate. The one comment carries the why that the type can’t.

See how the loop closes. The Getting started section assumes .env.example. The .env.example file lists what the build requires. env.ts enforces it at build time. And the README’s entire env-var section is a single link to env.ts. That’s three artifacts, one contract, and zero duplication, with no ENVIRONMENT.md anywhere, because a fourth copy would just be a fourth thing to get wrong.

Server Actions are this project’s API surface. That means each action is also the documentation for its own endpoint, and once you see how, you’ll understand why a Server Actions codebase doesn’t ship a separate API reference at all.

Here’s the createInvoice action. The body of the mutation is left out on purpose, because the lesson is in the contract, not the five-step write. Look only at what a caller would need to know to use this action correctly.

const createInvoiceSchema = z.object({
customerId: z.uuid(),
amountCents: z.number().int().positive(),
});
/**
* Creates a draft invoice for the active organization.
*
* @throws If the customer belongs to a different organization.
*/
export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { orgId }): Promise<Result<Invoice>> => {
// …
},
);

The Zod input schema is the input contract. It states exactly what a valid call looks like, a customer UUID and a positive integer amount, and it doesn’t merely describe the inputs; it’s the thing that validates them at runtime. There’s no separate “parameters” table that could quietly disagree with it.

const createInvoiceSchema = z.object({
customerId: z.uuid(),
amountCents: z.number().int().positive(),
});
/**
* Creates a draft invoice for the active organization.
*
* @throws If the customer belongs to a different organization.
*/
export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { orgId }): Promise<Result<Invoice>> => {
// …
},
);

The return type is the success-and-failure contract. Result<Invoice> is the course’s discriminated union, { ok: true; data } on success and { ok: false; error } otherwise, so a caller knows from the type alone that this can fail in expected ways and must be handled, not just wrapped in a try/catch.

const createInvoiceSchema = z.object({
customerId: z.uuid(),
amountCents: z.number().int().positive(),
});
/**
* Creates a draft invoice for the active organization.
*
* @throws If the customer belongs to a different organization.
*/
export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { orgId }): Promise<Result<Invoice>> => {
// …
},
);

The TSDoc carries the one thing the types can’t. Its first sentence is what an editor shows on hover, and the @throws line names a non-obvious failure mode, passing another org’s customer, that no signature would reveal. Input shape, result shape, and the surprises together are the API doc, and they live in the function itself.

1 / 1

A reader gets the complete contract from the function alone: the input shape from the Zod schema, the success-and-error shape from the Result<T> return type, and the non-obvious failure modes from the TSDoc. Producing this takes three small habits stacked together: keep the action small, type it precisely, and comment only the surprises. Do that, and you never write an API reference, because you’ve already written it without noticing.

This is the contrast from earlier, made concrete. A REST or tRPC project generates an API-reference document that can drift from its handlers. This codebase doesn’t generate one, because the function is the doc. It’s the same thesis on the API axis: docs live next to the truth.

CONTRIBUTING.md: when a separate file earns its place

Section titled “CONTRIBUTING.md: when a separate file earns its place”

One artifact is left hanging from the routing list: contribution guidelines. The default is the simple one. For a closed-source SaaS, which is what this course assumes throughout, there is no CONTRIBUTING.md. The people working in the repo are your team, and how the team works lives in AGENTS.md, which the next lesson builds.

What flips that is external contributors. An open-source library, or an open-by-policy repo where strangers open pull requests, earns a CONTRIBUTING.md that covers the PR process, a code-of-conduct pointer, and how to claim an issue. That’s the whole rule: closed-source, fold it into AGENTS.md; open to outsiders, give them a CONTRIBUTING.md.

Two reflexes carry the whole lesson.

The README has one job, first contact, and five sections: title and description, getting started, common tasks, where the docs live, and the license. Everything else routes to the file that owns it: reference to the source, conventions to AGENTS.md, decisions to /docs/adr/, roadmap to the issue tracker.

The second reflex is that docs live next to the truth. The schema is the data-model doc. env.ts plus .env.example is the env-var doc. The Server Action’s signature is the API doc. The README links to all of them and paraphrases none of them, because a paraphrase drifts silently the moment the code changes, while the source-as-doc can’t, since editing it is changing the code.

Carry one question into everything you write from here: before I write any paragraph stating a fact, like a column, an env var, or an action’s inputs, does that fact already live somewhere canonical? If it does, I link.

The README pointed at AGENTS.md for “how we work here” and left it deliberately unbuilt. That file is next: the home for the conventions and the heavier how-to that the thin README refuses to carry.