# Notes Is Prerendered Markdown, Not a CMS

> How the dil.dev/notes pipeline works: markdown files, a Vite plugin, strict frontmatter validation, and no CMS in sight.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-10-03
- Updated: 2026-10-03
- Tags: vite, markdown, json-ld, static-site
- Canonical: https://dil.dev/notes/notes-prerendered-markdown-not-a-cms/

## Key takeaways

- Posts live as plain markdown files in site/content/notes/<slug>.md, no database involved.
- A Vite plugin enforces frontmatter rules at build time, so invalid posts break the build rather than going live silently.
- Draft posts are noindexed and badge-labelled in dev; production builds exclude them entirely.
- Live posts must have takeaways, an FAQ, a featured image, and a What I'd do differently section before the build passes.
- RSS, sitemap, llms.txt and per-post markdown copies are all generated from the same source, live posts only.

The notes pipeline has no CMS, no admin panel, and no database. A post is a markdown file. A Vite plugin reads it, validates the frontmatter, and either prerendered HTML comes out the other end or the build fails loudly. That is the whole system.

## Where Files Live

Every note is a single file at `site/content/notes/<slug>.md`. The slug is the URL. A file called `my-finding.md` becomes `https://dil.dev/notes/my-finding/`. Nothing maps slugs to routes in a config file. The filesystem is the router.

The build logic lives in `site/lib/notes.mjs`, verified against the actual source on 2026-09-25. It is the single source of truth for every output that references notes: the sitemap, the RSS feed at `/notes/rss.xml`, `llms.txt`, and the per-post markdown copies that tools like LLM crawlers can fetch directly.

## Frontmatter Is a Contract

The frontmatter is not advisory. The Vite plugin treats it as a typed contract and breaks the build if the contract is violated. Required fields:

- **title**: plain string, shown in the page title and og:title.
- **date**: ISO date string, used for sorting and RSS pubDate.
- **status**: either `draft` or `live`, nothing else.
- **tags**: array, two to five short strings.
- **summary**: string, hard cap at 200 characters, used in meta description and RSS.
- **imageAlt**: describes the featured image for screen readers and structured data.
- **takeaways**: array, three to five complete-claim bullets.
- **faq**: array of `{q, a}` objects, minimum three items.

Optional fields are `updated`, `imageCaption`. That is the full list of permitted keys.

## The Field the Build Actively Rejects

Reading time is not a field here. Not optional, not deprecated. If `readingTime` or `reading_time` appears in frontmatter, the build rejects the post. The system sets the featured image path; the post author supplies `imageAlt` and optionally `imageCaption`, but never the image path itself.

This matters because it removes a whole category of stale metadata. Reading time estimates drift as posts are edited. Removing the field entirely means it cannot go wrong.

## Draft Behaviour

Status `draft` means the post is invisible in production. The production build filters it out before any rendering happens. It does not appear in the sitemap, RSS, or any index page.

In two situations drafts do render:

- **Local dev**: running the dev server shows drafts alongside live posts.
- **`NOTES_DRAFTS=1` preview**: setting that environment variable on a preview deploy also surfaces drafts.

In both cases the post gets a visible DRAFT badge and a `noindex` meta tag. It will not appear in search results even if someone shares the URL. The badge is a reminder, not a gate.

## What a Live Post Must Satisfy

Flipping `status` to `live` is not enough on its own. The build runs a checklist against every live post and fails if any item is missing:

- **Featured image**: the system must be able to resolve one. No image, no build.
- **Minimum three FAQ items**: the `faq` array needs at least three `{q, a}` pairs.
- **No unresolved placeholders**: any unfilled placeholder string causes a build failure. This is the mechanism that prevents half-finished notes from shipping.
- **A `## What I'd do differently` section**: the exact heading text must appear in the body. The build checks for it literally.

The em dash rule is enforced by convention rather than the build, but it is in the authoring spec: no em dash, no en dash anywhere in the file.

## Outputs Generated from the Same List

Once the live-posts list is assembled by `site/lib/notes.mjs`, several outputs are generated from it in one pass:

- **Sitemap**: every live post URL included with its date.
- **RSS** at `/notes/rss.xml`: full feed, live posts only.
- **llms.txt**: a plain-text index for LLM tools that crawl for context.
- **Per-post markdown copies**: raw markdown available at a predictable path, useful for tools that want the source rather than the rendered HTML.

Drafts appear in none of these. The filter is applied once, upstream, and everything downstream inherits it.

## Email Sign-ups

There is a subscription form on the notes index. It submits to the `notes-subscribe` Netlify Form. That is where it stops. Nothing is sent automatically when a post goes live. Sending is a deliberate manual step, which means a post can be live and indexed before any email goes out. That is a feature, not an oversight: it gives time to catch errors after the build but before notifying anyone.

## JSON-LD and Structured Data

Each live post gets JSON-LD structured data generated from the same frontmatter fields. `imageAlt` feeds into the image object. `summary` becomes the description. `takeaways` and `faq` map to FAQ structured data, which is part of why the minimum item count is enforced at build time rather than left to the author's discretion. A FAQ schema with two items is technically valid but practically useless for rich results.

If you are thinking about the SEO angle, the [every URL on the apex host](/notes/seo-every-url-on-the-apex-host/) note covers the broader URL and canonicalisation decisions that sit alongside this pipeline.

## What I'd do differently

The placeholder check is a string scan, which means a placeholder inside a code block would also fail the build. That has not caused a real problem yet, but it is a latent footgun. A smarter check would exclude fenced code blocks from the scan.

The email step being fully manual is fine for now, but as the post volume grows it will become a friction point. A Netlify Function triggered by a deploy-succeeded webhook, gated on a post's status being `live` and its date matching today, would remove the manual step without losing the deliberate delay. That is the obvious next build.

The draft badge is visual only. A more robust approach would be a middleware check that returns a 404 for draft URLs unless the preview flag is set, so draft URLs cannot be accidentally shared and indexed via a misconfigured preview.

## Quick answers

**Where do I add a new note?**

Create a markdown file at site/content/notes/<slug>.md. The slug becomes the URL path at https://dil.dev/notes/<slug>/. No CMS entry, no database row, just a file.

**What frontmatter fields are required?**

title, date, status, tags, summary (200 characters max), imageAlt, takeaways (3 to 5 items), and faq. The build rejects reading_time or readingTime if present. imageCaption and updated are optional.

**How do drafts work in production?**

Production builds include live posts only. In dev, or when NOTES_DRAFTS=1 is set, drafts also render but carry a DRAFT badge and a noindex meta tag so they stay out of search.

**What makes a live post fail the build?**

Missing featured image, fewer than three FAQ items, any remaining unresolved placeholder, or a missing ## What I'd do differently section. Fix all four and the build passes.

**Does the email subscription send automatically?**

No. Sign-ups go to the notes-subscribe Netlify Form. Nothing is dispatched automatically; sending is a manual step.

**Which output files reference notes?**

The sitemap, RSS feed at /notes/rss.xml, llms.txt, and per-post markdown copies all pull from the same live-posts list generated by site/lib/notes.mjs.
