Getting started with MILL
MILL, Markdown In, Living Layouts, is a Markdown → GRAIN-pages CMS: feed it a folder of .md files (frontmatter + prose) and it renders them as real GRAIN pages by mapping Markdown nodes to components. It sits a layer above the stack (batch → grain → MILL), depending on both and extending neither. The full mapping model is ARCHITECTURE.md; this page is the fastest path to a rendered page.
Install
MILL is published on the public npm registry as @tjakoen/mill, alongside @tjakoen/grain:
{
"dependencies": {
"@tjakoen/batch": "^0.1.0",
"@tjakoen/grain": "^0.1.12",
"@tjakoen/mill": "^0.2.0"
}
}That is the whole setup: no .npmrc, no auth token. (These packages lived on GitHub Packages until 2026-07-30, whose registry demands a token even for public packages; they are on npmjs now and install anonymously.)
Inside the grain monorepo itself, MILL is a sibling workspace package (workspace:*), no install step needed there.
A minimal collection
A collection is a folder of .md plus a MillCollection describing where it lives and what route prefix it answers:
import { createMillRoutes, dirSource } from "@tjakoen/mill/serve.ts";
const collections = [
{
prefix: "/docs",
title: "Docs",
description: "Guides, rendered from Markdown.",
source: dirSource("./content/docs"), // a folder of .md files
},
];
const serveContent = createMillRoutes({ collections });
// inside your own fetch handler, before your other routes:
const hit = await serveContent(new URL(req.url).pathname);
if (hit) return hit;createMillRoutes returns a transport-generic pathname handler, (pathname) => Promise<Response | null>. null means "not a MILL route", so the caller falls through to whatever serves everything else. With no compose passed, the default chrome ships as written (no component-tag expansion); pass compose (BATCH's renderPage) once you're wiring an app, so the chrome and any escape-hatch component tags in the Markdown compose at request time.
Verify it
bun run dev
# then visit /docs the index, built from every .md's frontmatter
# /docs/<slug> one entry, rendered from that file's body
# /docs/<slug>.md the raw source, MILL's "honest source" twin routeEvery filename minus .md, lowercased, becomes its slug (GETTING-STARTED.md → getting-started).
Next steps
- Read
ARCHITECTURE.mdfor the mapping model (frontmatter → layout, block →
component), the content-source port, and the escape hatch.
- Read
ADD-A-COLLECTION.mdfor the fullMillCollectionshape, using the
portfolio's own wiring as the worked example.
- See it live: this very page renders through MILL, and so do the layer docs it builds on,
GRAIN's getting-started and BATCH's getting-started.
- The canonical plan (design, seams, what's built vs. deferred) is