Blog
@opentf/web-docs ships a small blog feature alongside the docs generator. You write posts as MDX with frontmatter; the toolchain generates the post list and provides the layout and components (banner, meta, reading time, table of contents).
The blog on this site is the reference demo for this feature — the index, the post pages, cover images, author info, reading time, and the on-page TOC are all built with the components described here. Browse website/app/blog/ in the repo to see the exact files. It's intentionally not in the site's top navigation: it exists only to demonstrate @opentf/web-docs, not as this project's own blog — so the only link to it is right here in the docs.
Turn it on
Add a blog block to otfw.config.js
import { defineDocsConfig } from "@opentf/web-docs/config"; export default defineDocsConfig({ docs: { /* … */ }, blog: { dir: "blog", // posts live under app/blog (the default) }, });
This activates a build-time plugin that scans app/<dir> and resolves the virtual module @opentf/web-docs/posts to your post list — sorted newest-first, with reading time computed for you.
Write a post
Each post is a folder with a page.mdx. The body is pure content; the banner is built from frontmatter.
--- title: Hello world description: Our first post. date: 2026-06-25 author: Ada Lovelace author_avatar: /blog/ada.jpg author_role: Engineering cover: /blog/hello.jpg tags: announcements, web --- Your markdown here. No need to repeat the title or date — the layout renders the banner from the frontmatter above.
app/blog/hello-world/page.mdx → /blog/hello-world.
Add the layout
import { BlogLayout } from "@opentf/web-docs"; import { posts } from "@opentf/web-docs/posts"; import config from "../../otfw.config.js"; export default function (props) { return ( <BlogLayout config={config.docs} posts={posts} frame={false}> {props.children} </BlogLayout> ); }
BlogLayout shows the post banner + TOC on a post, and just the children on the index. Use frame={false} when your site already provides the navbar/footer.
Add the index
import { PostList } from "@opentf/web-docs"; import { posts } from "@opentf/web-docs/posts"; export default function BlogIndex() { return <PostList posts={posts} />; }
Frontmatter fields
| Field | Type | Notes |
|---|---|---|
title | string | Falls back to a humanized slug. |
description | string | Shown on the banner and the index card. |
date | string | YYYY-MM-DD; drives newest-first ordering. |
author | string | Name shown in the meta row. |
author_avatar | string | Image URL shown next to the name. |
author_role | string | Optional label after the name. |
cover | string | Cover image (banner + card). |
tags | string | Comma-separated; rendered as chips. |
order | number | Overrides date ordering when present. |
lastUpdated | string | false | Override the updated date, or false to never show it. |
Values are scalars (string/number/boolean). tags is a comma-separated string that the plugin splits into an array — YAML lists aren't parsed.
The generated post list
@opentf/web-docs/posts exports the array used by the index and the layout. Each entry is plain data, so you can build your own listing if you don't want PostList:
import { posts } from "@opentf/web-docs/posts"; // posts: { slug, path, title, description, date, author, authorAvatar, // authorRole, cover, tags, readingTime, order }[]
Components
| Component | Use |
|---|---|
BlogLayout | Section layout — banner + TOC on posts, children on the index. |
PostList / PostCard | The index listing. |
PostBanner | Cover + title + description + meta (used by BlogLayout). |
PostMeta | The date · author · reading-time row. |
ReadingTime | A N min read label. |
Search
Posts are indexed automatically. BlogLayout marks the post article with data-pagefind-body, so otfw build --ssg picks blog posts up in the same Pagefind index as your docs — no extra configuration. The index page is intentionally not indexed (it's just a list of links).
Last updated
Set blog.lastUpdated: true to show a "Last updated" line on a post — but only when it was edited after its publish date, so a freshly published post stays clean. The date comes from the file's last git commit (or a lastUpdated frontmatter override). See Configuration → Last updated for the details this shares with docs pages.
Feeds
otfw build writes RSS 2.0 and Atom 1.0 feeds to <dir>/rss.xml and <dir>/atom.xml (e.g. /blog/rss.xml and /blog/atom.xml) whenever a blog block is configured. site.url is required so every feed URL is absolute. Posts appear newest-first with absolute links, dates from date, author metadata, and categories from tags.
export default defineDocsConfig({ site: { url: "https://example.com" }, // required — feeds need absolute URLs blog: { dir: "blog", title: "Example Blog", // feed title (optional) description: "Notes from the team", // feed description (optional) }, });
Add an autodiscovery link to your index.html <head> so readers and browsers find it:
<link rel="alternate" type="application/rss+xml" title="Example Blog" href="/blog/rss.xml" /> <link rel="alternate" type="application/atom+xml" title="Example Blog" href="/blog/atom.xml" />
Drop a public/<dir>/rss.xml or public/<dir>/atom.xml to take over either feed — the generator detects each override independently and won't overwrite it. Feeds are skipped only when both generated feed files are overridden.