Skip to content

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/src are 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.

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.

---
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 | data
industry: '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' # optional
duration: '5 years' # optional
team: 'Two engineers, one designer' # optional
releasedDate: 2026-05-01 # required
updatedDate: 2026-06-01 # optional
hero: ./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 set
heroCredit: 'Photo by Someone on Unsplash' # optional caption rendered under the hero
gallery: # optional
- { src: ./image-1.jpg, alt: 'Before/after of the cart step' }
featured: true
order: 6 # sort weight on the work index
comingSoon: false
legacySlug: 'acme-checkout' # optional; the legacy /cases/<slug> path, for redirect generation
draft: false
seo: # 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.
---
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-15
updatedDate: 2026-05-20 # optional
author: 'Quarry' # a plain string; defaults to 'Quarry'
category: 'Engineering' # optional
tags: ['engineering', 'performance']
hero: ./hero.jpg # optional; a local image beside index.mdx
heroAlt: 'Descriptive alt text' # required when hero is set
heroCredit: 'Photo by Someone on Unsplash' # optional
draft: false
---
Body in MDX.

Blog slugs migrated from the legacy site must match the existing /blog/<slug> to preserve SEO (ADR 0011).

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 default
type: 'Full-time' # optional; this is the default
summary: 'One-line teaser for the careers index.'
datePosted: 2026-05-01
validThrough: 2026-07-01 # optional
order: 1
draft: false
---
Body in MDX.

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 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.

  • 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, gallery src: ./image-1.jpg, avatars ./avatars/<id>.jpg). Astro’s image pipeline generates optimized, responsive variants at build (ADR 0006).
  • Every image needs meaningful alt text — accessibility (WCAG 2.2 AA) is a CI gate. Use alt: "" 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:fix fixes anything the CI Image budgets check 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 budgets check therefore rejects any .gif in apps/web/src/{content,assets}. Author motion as animated WebP (e.g. sharp(src, { animated: true }).webp() or gif2webp), which is held to the same ≤900 KB byte budget as any other raster.

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

  • Lead case studies with the outcome, then the approach.
  • Keep slugs stable; set draft: true while drafting.
  • Write specific alt text and accurate summary/description (they become meta descriptions, held to 70-160 characters).
  • Run pnpm typecheck to 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:fix normalize 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: true content 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.

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.