Blog posts are MDX files in packages/app/content/blog/, compiled at build time via next-mdx-remote. This was chosen over a CMS or database because:
- No runtime dependency: Posts are part of the repo, versioned in git, reviewed in PRs. No CMS outage can break the blog.
- MDX flexibility: Authors can embed custom React components (
Figure,Blur) alongside Markdown. This matters for image-heavy technical articles with captions, paywall teasers, and code blocks. - Static generation:
generateStaticParams()pre-renders all post pages. No server-side rendering at request time.
Syntax highlighting uses Shiki with dual light/dark themes (CSS class-based switching, not runtime theme detection).
# Frontmatter (required: title, subtitle, date)
title: string
subtitle: string
date: YYYY-MM-DD
modifiedDate?: YYYY-MM-DD # Used in sitemap and JSON-LD
publishDate?: YYYY-MM-DD # Scheduled publishing, hidden in production until this date
tags?: string[] # Used for filtering on /blog and in RSS categoriesPosts without publishDate are hidden in production — this field is required for a post to be visible. If publishDate is set to a future date, the post is hidden until that date arrives. In development, all posts are visible regardless. This allows articles to be merged to master via PR and go live automatically when the date arrives. All downstream consumers (sitemap, RSS, llms.txt) automatically respect the filter since they call getAllPosts(). getPostBySlug() still returns the post regardless of publishDate (for direct URL preview).
Slug is derived from the filename (e.g., my-post.mdx -> my-post), not from frontmatter. Reading time is calculated at 265 WPM.
| Component | Usage | Notes |
|---|---|---|
# / ## / ### |
Headings with auto-generated IDs | IDs are deduped: second ## Details under ## Results becomes results-details |
[text](url) |
Links | Internal links use <Link>, external get target="_blank" |
 |
Images | Rendered via next/image with lazy loading (first image is eager) |
<Figure src="..." alt="..." caption="..." /> |
Captioned figures | Uses <img> (not next/image) for external URLs |
<Blur>...</Blur> |
Paywall teaser blur overlay | Content is blurred, unselectable, and not clickable |
<JsonLd>{...}</JsonLd> |
Structured data (JSON-LD) | Renders <script type="application/ld+json">. Validates JSON before rendering. |
Heading ID deduplication: if two headings share a slug, the second gets prefixed with its parent heading's slug (e.g., overview-details). If no parent exists, a level suffix is appended (intro-2).
| Function | Purpose |
|---|---|
getAllPosts() |
All posts sorted newest-first |
getPostBySlug(slug) |
Single post meta + raw MDX content |
getAdjacentPosts(slug) |
{ prev, next } — prev is older, next is newer |
extractHeadings(rawMdx) |
h1-h3 headings with unique IDs (strips code blocks first) |
slugify(raw) |
URL-safe slug generation |
getReadingTime(content) |
Word count / 265, minimum 1 minute |
1200x630px images generated at build time with next/og (Satori). Design: decorative tile sidebar + dark content panel with title, subtitle, date, and logo. Title font size scales (56-72px) based on length for readability at thumbnail sizes.
RSS 2.0 with Dublin Core and Atom extensions. Includes all posts with title, link, description, creator, pubDate, categories. Cached 1 hour.
/llms.txt: Site description + article index with titles, URLs, and subtitles/llms-full.txt: Full raw MDX content of every post, for LLM context ingestion
Blog index at priority 0.8 (weekly), individual posts at priority 0.7 (monthly, uses modifiedDate if present).
/blogpage:Blogschema/blog/[slug]page:BlogPostingschema (headline, author, publisher, dates, wordCount, timeRequired)
Two modes based on available viewport space:
- Sidebar (>= 240px right of content): Fixed position, follows scroll via imperative DOM updates in a scroll handler. Active heading tracked via
IntersectionObserverwithrootMargin: '0px 0px -80% 0px'. Falls back to last heading when scrolled to page bottom. - Inline (narrow screens): Collapsible
<details>card.
The sidebar position is calculated relative to the [data-blog-section] element and updated on scroll/resize.
Fixed-top 0.5px bar tracking scroll position within the <article> element. Fires milestone events at 25/50/75/100% thresholds (each fires only once per page load).
Copy-to-clipboard button shown on heading hover. State cycle: idle -> copied ("Link copied" text) -> fade out -> idle.
Previous (older) / Next (newer) post links with title display. Uses getAdjacentPosts().
All blog analytics use the blog_ prefix per the [section]_[action] convention:
| Event | Trigger |
|---|---|
blog_post_clicked |
Click post card on list page |
blog_toc_clicked |
Click TOC heading |
blog_read_milestone |
Scroll past 25/50/75/100% |
blog_heading_link_copied |
Copy heading link |
blog_nav_prev / blog_nav_next |
Click prev/next post |
blog_back_clicked |
Click back to articles |
blog_tag_filtered |
Click tag filter |
social_share_twitter / social_share_linkedin |
Click share buttons |
- Create
packages/app/content/blog/<slug>.mdxwith required frontmatter (title,subtitle,date) - Add optional
tags,modifiedDate, andpublishDatefrontmatter - Write content using standard Markdown + available MDX components
- The post automatically appears in: blog list, sitemap, RSS feed, llms.txt, OG image generation
- No code changes needed — just the MDX file