Blog
Manage blog content and articles.
Overview
Blog content lives in web/next/content/blog/ as MDX files. The blog is content-driven: each post owns its metadata in frontmatter, and the listing, ordering, and meta.json are all derived from the posts. Adding a post is a single file, with no list or meta.json to maintain by hand.
createdAt is bookkeeping; publishedAt is consent to publish. A post is public only with a non-future publishedAt and not marked as a draft.
Creating a Blog Post
- Create a new
.mdxfile inweb/next/content/blog/. - Add frontmatter with
title,description,createdAt, andpublishedAtfor public posts, plus optionalupdatedAt,draft,author, andtags.createdAt,updatedAt, andpublishedAtcan beYYYY-MM-DDdates or ISO datetimes with timezone offsets:
---
title: "My Post Title"
description: "A short summary of the post."
createdAt: 2026-06-11
publishedAt: 2026-06-11
---Use publishedAt when a post needs to go live at an exact time:
---
title: "Scheduled Launch"
description: "A post scheduled for 9 AM IST."
createdAt: 2026-06-11
publishedAt: "2026-06-18T09:00:00+05:30"
---Use the same timestamp shape for createdAt or updatedAt when the exact time matters:
updatedAt: "2026-06-20T14:30:00+05:30"- Write the post body in MDX.
That is the whole workflow. Published posts appear on /blog automatically (newest first by publishedAt), and the listing updates itself. You never edit the listing or meta.json by hand.
Drafts and Scheduled Posts
Add draft: true to hide a post regardless of its timestamps:
---
title: "My Draft"
description: "A post that is not ready yet."
createdAt: 2026-07-01
draft: true
---A post is public only when draft is not true and publishedAt exists and is not in the future. Omit publishedAt for drafts that do not have a release time yet. Date-only timestamp values mean the start of that UTC day; exact-time values must include Z or an offset such as +05:30. Blog post and OG routes prebuild paths for posts published at build time, and every public blog surface re-evaluates publishing during render/revalidation, so scheduled posts do not need a rebuild to become accessible. The first request after publishedAt may still see stale cached output while ISR regenerates; the post should become visible within about 60 seconds.
Images for a post are committed as static assets under web/next/public/blog/<slug>/images/ and referenced by absolute path, e.g. .
How It Works
- Schema: the
blogcollection inweb/next/source.config.tsrequirescreatedAt(and accepts optionalupdatedAt/publishedAt/draft/author/tags), so every post is validated at build time. Runningbun .github/scripts/docs.tsadds a missingcreatedAtwith the local date for new blog files and prints the value it wrote; review and commit that frontmatter. Strict builds validate without writing. - Publishing:
web/next/src/lib/blog-policy.tsdefines the shared rule for published posts: not draft andpublishedAtis present and not future-dated.web/next/src/lib/blog.tsadapts that rule to Fumadocs pages for the blog routes, listing, sitemap,llms.txt, and OG image routes. - Listing:
/blogrenders the<BlogPostList />component (web/next/src/components/blog/post-list.tsx), which displays published posts newest-first bypublishedAt, and renders "No posts published yet." when the published set is empty.web/next/content/blog/index.mdxonly holds the page intro plus<BlogPostList />. - Article metadata: individual blog pages display their publish/update dates and emit article Open Graph metadata from the same frontmatter.
- meta.json:
web/next/content/blog/meta.jsonis generated by the docs generator (.github/scripts/docs.ts,generateBlogMeta) from posts published at generation time (index first, then newest-first bypublishedAt) and is git-ignored. It drives the Fumadocs page-tree with no hand-maintenance, so never edit it by hand. A scheduled post can become public before it appears in page-tree navigation;/blogis the canonical public listing. - updatedAt: optional
updatedAtvalues are validated likecreatedAtand are used as the blog pagelastModifiedvalue insitemap.xml.
meta.json is generated and git-ignored. Never edit it by hand.
Configuration
The blog source and its frontmatter schema are configured in web/next/source.config.ts:
import { normalizeBlogTimestamp } from "@/lib/blog-policy"
const blogTimestampSchema = z
.union([z.iso.date(), z.iso.datetime({ offset: true }), z.date()])
.transform((value, ctx) => {
const timestamp = normalizeBlogTimestamp(value)
if (timestamp) return timestamp
ctx.addIssue({
code: "custom",
message: "Expected YYYY-MM-DD or ISO datetime with timezone",
})
return z.NEVER
})
const blogSchema = pageSchema.extend({
createdAt: blogTimestampSchema,
updatedAt: blogTimestampSchema.optional(),
publishedAt: blogTimestampSchema.optional(),
draft: z.boolean().optional(),
author: z.string().optional(),
tags: z.array(z.string()).optional(),
})
export const blog = defineDocs({
dir: "content/blog",
docs: {
schema: blogSchema,
postprocess: { includeProcessedMarkdown: true },
},
})