Guide: Content authoring
Reference for authoring case studies and blog posts as MDX in the git-based content collections (ADR 0004). For the workflow (git-CMS vs MDX, publishing), see ../runbooks/content-editing.md.
The Zod schemas in
apps/web/srcare authoritative. This guide documents the intended shape; if a field here disagrees with the schema, the schema wins (and this guide should be updated). Required fields are enforced at build time — missing/invalid frontmatter fails the build.
Where things live
Section titled “Where things live”apps/web/src/content/ cases/<slug>/index.mdx -> /work/<slug> (folder-per-entry; images sit beside index.mdx) posts/<slug>/index.mdx -> /blog/<slug> (folder-per-entry; images sit beside index.mdx) roles/<slug>.mdx -> /careers/<slug> (flat MDX) services/services.json (one data file, not per-service MDX) team/team.json (one data file, not per-member MDX)Cases and posts are folder-per-entry so their images live beside the entry; the folder
name is the URL slug. services and team are data collections (one JSON file each).
Keep slugs lowercase, hyphenated, and stable: changing a published slug breaks links and SEO.
Frontmatter reference
Section titled “Frontmatter reference”Case study (cases/<slug>/index.mdx)
Section titled “Case study (cases/<slug>/index.mdx)”---title: 'Rebuilding checkout for Acme'client: 'Acme, Inc.'summary: 'A short, outcome-led one-liner shown on the work index. When no seo.description is set it also becomes the meta description, so keep it 70-160 characters with no trailing ellipsis.'disciplines: ['engineering', 'data'] # any of: strategy | design | engineering | ecommerce | dataindustry: 'Fintech' # optional# The outcome model: a declarative headline, an optional 1-2 word kicker, and# genuine numerals ONLY (no words-as-stats). `supporting` requires `stat`.outcome: kicker: 'Acquired.' # optional 1-2 word opener with real punctuation headline: 'A falsifiable, declarative sentence sourced from the body.' # required stat: { value: '10×', label: 'revenue growth' } # optional; genuine numerals only supporting: # optional, max 2 - { value: '500k', label: 'avg weekly pageviews' }role: 'Embedded product & engineering team' # optionalduration: '5 years' # optionalteam: 'Two engineers, one designer' # optionalreleasedDate: 2026-05-01 # requiredupdatedDate: 2026-06-01 # optionalhero: ./hero.jpg # optional; a local image beside index.mdx (run through the image pipeline)heroAlt: "Acme's redesigned checkout on mobile" # required when hero is setheroCredit: 'Photo by Someone on Unsplash' # optional caption rendered under the herogallery: # optional - { src: ./image-1.jpg, alt: 'Before/after of the cart step' }featured: trueorder: 6 # sort weight on the work indexcomingSoon: falselegacySlug: 'acme-checkout' # optional; the legacy /cases/<slug> path, for redirect generationdraft: falseseo: # optional; falls back to title + summary title: 'Acme checkout rebuild — Quarry' description: "How we rebuilt Acme's checkout as an embedded team, in 70-160 characters."---
Body in MDX. Lead with the outcome, then the approach.Blog post (posts/<slug>/index.mdx)
Section titled “Blog post (posts/<slug>/index.mdx)”---title: 'Why we ship zero JavaScript by default'description: 'The required meta description shown on the index and as the SERP snippet. Must be 70-160 characters with no trailing ellipsis.'publishedDate: 2026-05-15updatedDate: 2026-05-20 # optionalauthor: 'Quarry' # a plain string; defaults to 'Quarry'category: 'Engineering' # optionaltags: ['engineering', 'performance']hero: ./hero.jpg # optional; a local image beside index.mdxheroAlt: 'Descriptive alt text' # required when hero is setheroCredit: 'Photo by Someone on Unsplash' # optionaldraft: false---
Body in MDX.Blog slugs migrated from the legacy site must match the existing
/blog/<slug>to preserve SEO (ADR 0011).
Role (roles/<slug>.mdx)
Section titled “Role (roles/<slug>.mdx)”Open roles for the careers page. The MDX body is the full description.
---title: 'Senior Full-Stack Engineer'team: 'Engineering'location: 'Remote — global' # optional; this is the defaulttype: 'Full-time' # optional; this is the defaultsummary: 'One-line teaser for the careers index.'datePosted: 2026-05-01validThrough: 2026-07-01 # optionalorder: 1draft: false---
Body in MDX.Service (services/services.json)
Section titled “Service (services/services.json)”Services are a data collection — one JSON array, not per-service MDX. Each entry:
{ "id": "engineering", "title": "Full-Stack Engineering", "summary": "One-line description of the capability.", "capabilities": ["Product ownership & discovery", "UX research & UI design"], "discipline": "engineering", "order": 2}discipline is optional, one of strategy | design | engineering | ecommerce | data.
Team (team/team.json)
Section titled “Team (team/team.json)”Team members are a data collection — one JSON file, not per-member MDX:
{ "id": "jane-doe", "name": "Jane Doe", "role": "Principal Engineer", "location": "Portland, OR", "bio": "Short bio.", "avatar": "./avatars/jane-doe.jpg", "lat": 45.5, "lng": -122.7, "order": 1}lat/lng place the globe pin and are city-level only: at most one decimal place (a
~10 km privacy floor enforced at build, so a finer coordinate fails the build). Never commit
a geocoded address for a named person.
avatar is a path relative to team.json — put the photo in
apps/web/src/content/team/avatars/ (not public/). It runs through Astro’s
image pipeline and is rendered with the <Image> component at display size,
so commit a reasonably sized original and let the build do the rest.
Roster avatars are black-and-white. Fully desaturate every avatar before
committing (e.g. sharp(src).grayscale()) — the whole roster carries the same
monochrome treatment, and a color avatar is a visual defect. This has shipped
in color twice (f9ce62c, PR #88), so treat it as a checklist item, not a
style suggestion. A face-centered square crop (~800×800 JPEG) keeps the 72px
roster circle sharp at 2× density.
Image handling
Section titled “Image handling”- Images live beside the entry and are committed to the repo: put them in the case/post folder and reference them relatively (
hero: ./hero.jpg,gallerysrc: ./image-1.jpg, avatars./avatars/<id>.jpg). Astro’s image pipeline generates optimized, responsive variants at build (ADR 0006). - Every image needs meaningful
alttext — accessibility (WCAG 2.2 AA) is a CI gate. Usealt: ""only for purely decorative images. - Provide appropriately sized sources; let the image pipeline generate responsive variants. Committed content images are held to a budget (≤2560px long edge, ≤900 KB) — the pipeline only downscales, so bigger sources just weigh down the repo. The pre-commit hook auto-normalizes staged images;
pnpm images:fixfixes anything the CIImage budgetscheck flags. - Motion uses animated WebP, not GIF. Astro’s build can’t emit an animated derivative, so a committed GIF would be shipped whole (a multi-MB payload) or silently de-animated — the
Image budgetscheck therefore rejects any.gifinapps/web/src/{content,assets}. Author motion as animated WebP (e.g.sharp(src, { animated: true }).webp()orgif2webp), which is held to the same ≤900 KB byte budget as any other raster.
Using components in MDX
Section titled “Using components in MDX”Posts and cases may embed a constrained set of design-system components (e.g. callouts, figures, pull quotes). Keep editorial content mostly prose; reach for components for structure, not decoration. Components inherit the design tokens — never hardcode colors or spacing.
Do’s and don’ts
Section titled “Do’s and don’ts”Do
- Lead case studies with the outcome, then the approach.
- Keep slugs stable; set
draft: truewhile drafting. - Write specific
alttext and accuratesummary/description(they become meta descriptions, held to 70-160 characters). - Run
pnpm typecheckto validate frontmatter before opening a PR.
Don’t
- Don’t commit oversized images — keep them within the budget (the pre-commit hook and
pnpm images:fixnormalize staged images). - Don’t change a published slug without a redirect.
- Don’t hardcode brand colors/spacing in MDX — use components/tokens.
- Don’t ship
draft: truecontent expecting it to appear (it’s excluded from build, sitemap, and search). - Don’t rely on frontmatter fields the schema doesn’t define: Zod silently strips unknown keys, so they do nothing (no build error, but no effect either). Missing or malformed required fields do fail the build.
Punctuation: the em-dash rule
Section titled “Punctuation: the em-dash rule”Adopted from the June 2026 copy audit (dispo R1). Heavy em-dash use is a calling card of machine-written prose, and ours had 119 in chrome alone.
- An em dash must mark a true interruption or pivot that no other mark can carry. If the dash could be deleted or swapped for a period, comma, colon, or parentheses without loss, swap it.
- Never more than one em dash per screen of copy.
- Never end a sentence with a dash-coda (“…— and it shows.”). Give the payoff its own sentence.
- Page
<title>separators (Page — Quarry) are exempt; that is hardware, not prose.