Building a real course with Claude
Three artifacts already sitting in this codebase — a typed lesson schema, one pilot lesson, and a production incident with a real PR number — teach more about this than a tutorial would
10 min read
This site's own content/skills.ts describes the skill this lesson is drawn from in one sentence worth reading twice: it exists to prevent "a course build that reinvents commerce or auth the platform already has, spends its whole budget on one subject before validating the lesson pattern on a single pilot, or treats retrieval practice as prose pasted into markdown when the platform needs typed data to grade against it," and it was "distilled from rebuilding Meridian end to end in one long session, including a production-deploy crisis root-caused live against the database and a copyright flag on an official question bank handled explicitly rather than skipped." Every clause in that sentence is a scar, not a guess. This lesson walks through three of them using the actual files, the actual PR, and the actual production incident — not a reconstruction of what building a course with Claude is like in general.
Prose is not data: why anything gradable has to be typed
content/quant/, the course you might be reading this one next to, is markdown. scripts/build-quant.mjs says so about itself, in its own header comment: "a first-pass, barebones (IDS-style) build: markdown modules compiled straight to folder/lesson nodes, no typed lesson/quiz blocks yet." The script reads an H1 as the title, an immediately-following H2 as the standfirst, strips both, and stores everything else as one body string of prose. That's fine for QUANT, because nothing in it needs to be marked — a reader either follows the argument about order-flow imbalance or they don't, and there's no answer key to check them against.
Meridian, the SAT-prep course this whole skill was distilled from, could not have been built that way, and content/meridian/types.ts says exactly why in its own opening comment: "Every lesson is data, not markdown, for the same reason content/protocol/* is data: a prequestion, a faded worked example, and an MCQ with a tagged misconception per wrong choice don't fit prose without inventing brittle markdown syntax." Look at what that file's MCQ type actually carries: four Choice objects, each with a mandatory explanation field — "not just the correct one... 'never ship an MCQ without feedback, anywhere, ever'" — and an optional misconception tag on the wrong ones, because "wrong answers are data; tagging them turns the distribution into a diagnostic instead of a tally." None of that is expressible as markdown a human reads and nods along to. It's expressible as a TypeScript object a renderer can iterate over, check a submitted answer against correctId, and look up the matching misconception string when the student gets it wrong. Prose can explain a concept. It cannot be the thing a handleSubmit function pattern-matches on.
content/veridian/types.ts — the sibling course for Pearson IAL Maths, Economics, and Business — makes the same case from a different angle: a SpecRef type that points at "the actual specification document, not a house numbering scheme layered on top of it," because the reference material Veridian replaced had once cited a spec point, "3.3.1.4," that doesn't exist in the real Pearson document; a SourcedQuote type where verified: true is, in the type's own comment, "a literal, not a boolean, on purpose — there is no verified: false state for this type" — because, in the same comment's words, a claim that hasn't been checked against the primary source "isn't a SourcedQuote at all; it's either not written yet, or it belongs in BeyondSpec, labelled as inference rather than dressed as a citation." That's not decoration. It's the type system doing editorial enforcement that a markdown file has no mechanism for at all.
And the database schema underneath both courses makes the split literal: lib/db/courses.ts's ensureSchema() creates a course_nodes table with a plain body TEXT column next to two JSONB columns, lesson_json and quiz_json, with the choice documented in the code itself — "A course with a typed content model (prequestion/worked-example/mcq blocks, not markdown) carries its lesson/quiz here instead of body... a node has exactly one or the other, never both." QUANT and this course you're reading fill body. Meridian and Veridian fill lesson_json. Same table, same pipeline, genuinely different content model, because the content is doing a genuinely different job.
The copyright flag in that same skills.ts sentence is the sharpest case of typed data doing real work rather than decorative work. Meridian's MCQ type carries one more optional field: source?: "college-board", documented as "Set only on items that are verbatim, unmodified College Board content, ported from the official Digital SAT Question Bank — not Meridian-original. Absent (not false) on every original item." That single field is the entire mechanism by which a few thousand ported official test questions stay distinguishable from Meridian's own, at the level of individual items, forever, without anyone having to remember which batch came from where. A markdown file has no equivalent of an optional field absent by default on everything except the exact items that need the flag. A prose paragraph saying "some of these questions are from the real test" is a claim about the file. A typed field on every single item is a property of the data, checkable by a script, not a promise anyone has to keep by memory.
Pilot one lesson before you build thirty
content/veridian/curriculum/wma11/quadratic-functions.ts is not a comment in code — it's an actual real lesson file, 1,008 lines, on quadratic functions and the discriminant. Its own header block names what it is in plain terms: "the PILOT Maths lesson. It is the first WMA11 lesson, has no prerequisites, and is the first lesson anywhere in this course to use the Maths-native block types (marked-solution, method-comparison) and the MarkLine/MarkedSolution/MethodComparison types instead of Economics' LevelExemplar and ConditionalJudgementDrill." The same comment records something a tutorial would skip past: the spec's numbering doesn't fit the existing SpecRef shape cleanly for Maths the way it did for Economics, so the pilot is also where that mismatch got found, documented, and worked around — "SpecRef's three-field shape carries no information for Maths that section alone doesn't" — before it was baked into eleven more lessons per paper. A pilot's job isn't just proving the pattern works. It's finding the places the pattern that worked for the last subject quietly doesn't fit the next one, while the cost of being wrong is one file instead of thirty.
The cost of getting the other half of piloting wrong is documented in this repo too, and it isn't hypothetical. WMA12 and WMA13 — the two Maths papers after the pilot — were each fully built to spec: eleven lessons apiece, every spec point covered, verified against the research facts files, committed to the repository. And for a stretch, every one of those 22 finished lessons was unreachable by any URL on the live site, because content/veridian/index.ts still only imported the single WMA11 pilot lesson, and content/courses.ts had no wma12/wma13 course entries at all. The content existed. The build was clean. Nobody had wired the second and third batches into the two places a lesson actually has to be registered to become a page. That's the shape piloting-gone-wrong actually takes in practice: not a broken pattern, but a completed batch of real work that scaling silently forgot to connect, found only by a fresh audit pass rather than by the build succeeding — because tsc and npm run build have no way to know a lesson file exists that nothing imports.
Twenty-Three Agents and What Broke — the article this whole skill traces back to — names the general version of that same discipline for anything built at scale with agents, not just lesson content: "don't decide in advance how many agents the fix phase needs... Force the research phase to hand back a structured list, not prose, an actual array, and size the implementation phase off however many things turned up." A fixed plan sized before you've seen the real scope is the same mistake at a different layer — deciding "22 lessons, done" is true the moment the files exist, instead of checking whether the thing that makes a lesson real, being reachable at a URL, actually holds for all 22 and not just the one that got hand-verified.
The production incident: root-causing a build failure against the live database
This is the part of the skill's own sentence that sounds like a war story and is one, with a deployment ID attached. On 2026-08-28, Vercel deployment dpl_AFXKJdPEnZehzinBuyvhh2RMBeiU (commit 4e50024) failed to build, for real: /courses timed out at 60 seconds, three retries, all failed, "Export encountered an error... exiting the build." Not deploy lag. A genuine build failure blocking every commit to main from reaching production.
The root cause took reading two files, not guessing. app/courses/page.tsx sets export const revalidate = 30, which means Next.js still fully pre-renders the page at build time. And lib/db/courses.ts's ensureSchema() seeded each course's lesson nodes with one sequential await sql\INSERT...`per node — fine when a course had a handful of nodes, not fine oncewec13had "dozens of nodes with much largerlesson_jsonpayloads" and itsseedVersionhad been bumped three times in one day, each bump forcing a full delete-and-reinsert of every node, sequentially, while Vercel was concurrently generating roughly 270 other static pages against the same pooled database connection. The current code documents the exact number: "awaiting each row sequentially — inside a build that's already rendering ~270 pages concurrently against the same pooled connection — is what blew acourses` static-generation pass past Vercel's 60s/page budget."
The fix, in PR #168, replaced the per-node loop with one multi-row INSERT via postgres.js's sql(array, ...cols) helper — same columns, same ON CONFLICT semantics, lesson_json/quiz_json routed through sql.json(...) rather than a manual JSON.stringify(...)::jsonb cast, with an explicit guard for an empty node array, which the bulk-insert helper would otherwise render as a bare VALUES clause with nothing after it — a SQL syntax error waiting for the first course with zero nodes. Before that PR was trusted, the same failure was confirmed to recur on a second, independent production build (dpl_Awx1KMRYJjLdj4RE6NE6oKsvgTHh, commit bc92a83, error code BUILD_UTILS_SPAWN_1) — the same /courses timeout on unrelated code, which is what separated "a one-off Vercel flake" from "the deploy pipeline is genuinely stuck and every commit to main is failing to reach production."
The part worth taking as a template, not just a story: the PR was deliberately left open and unmerged after tsc and npm run build came back clean, because this repo's own convention is that "local dev has no DATABASE_URL" — a clean typecheck proves the code compiles, not that the SQL is correct against a real database. Verification meant branching off a fresh origin/main in an isolated worktree, then running the actual bulk INSERT ... ON CONFLICT DO NOTHING — with nested JSONB payloads shaped like real lesson content — against the live Supabase database backing this app (project ctfgobescjqciivwsxxj), inside an isolated scratch table carrying course_nodes' exact column shape and primary key. A row that intentionally conflicted with an existing one was left untouched, nested JSONB round-tripped correctly, and a null lesson/quiz column came back as a true SQL NULL rather than a stored JSON null value — a distinction the fix's own code comments call out explicitly, because the two are different values to a query that later checks IS NULL. The scratch table was dropped immediately after, and course_projects/course_nodes row counts were confirmed unchanged — 30 and 1,058 — before and after, so the verification touched nothing that any real page depended on. Only after that did the PR merge.
What this doesn't make true
None of this means the pattern is fixed everywhere it exists. lib/db/articles.ts has a structurally similar sequential-await loop seeding this site's articles — one claim, one insert, per article, not batched — and the backlog entry that names PR #168's fix says plainly that this second instance was deliberately left alone: fewer articles than course nodes, no observed timeout on /articles, and a genuinely different shape, since a course's nodes belong to one already-claimed course while an article's claim and its insert happen together, so the same batching approach "doesn't drop in directly" without decoupling the claim from the insert first. That's the honest-limits half of this lesson, not an afterthought to it: knowing exactly which fire you put out, and saying so plainly about the ones still sitting there, is the same discipline as the typed-source field on a ported SAT question — a property of the system you can check, not a promise about competence you're asking to be trusted on.
Up next
The honest limits: what none of this fixes
A production course whose own file history runs past two dozen separate fix passes, a silent failure that reported a confident wrong number, and the bias a same-model reviewer carries into its own…
5 min