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

See it live

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

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

MDXapp/blog/hello-world/page.mdx
---
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

JSXapp/blog/layout.jsx
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

JSXapp/blog/page.jsx
import { PostList } from "@opentf/web-docs";
import { posts } from "@opentf/web-docs/posts";

export default function BlogIndex() {
  return <PostList posts={posts} />;
}

Frontmatter fields

FieldTypeNotes
titlestringFalls back to a humanized slug.
descriptionstringShown on the banner and the index card.
datestringYYYY-MM-DD; drives newest-first ordering.
authorstringName shown in the meta row.
author_avatarstringImage URL shown next to the name.
author_rolestringOptional label after the name.
coverstringCover image (banner + card).
tagsstringComma-separated; rendered as chips.
ordernumberOverrides date ordering when present.
lastUpdatedstring | falseOverride the updated date, or false to never show it.
Frontmatter is flat

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:

JSX
import { posts } from "@opentf/web-docs/posts";
// posts: { slug, path, title, description, date, author, authorAvatar,
//          authorRole, cover, tags, readingTime, order }[]

Components

ComponentUse
BlogLayoutSection layout — banner + TOC on posts, children on the index.
PostList / PostCardThe index listing.
PostBannerCover + title + description + meta (used by BlogLayout).
PostMetaThe date · author · reading-time row.
ReadingTimeA N min read label.

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.

JavaScriptotfw.config.js
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:

HTMLindex.html
<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" />
Bring your own feed

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.