ZeroStarter

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

  1. Create a new .mdx file in web/next/content/blog/.
  2. Add frontmatter with title, description, createdAt, and publishedAt for public posts, plus optional updatedAt, draft, author, and tags. createdAt, updatedAt, and publishedAt can be YYYY-MM-DD dates 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"
  1. 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. ![Diagram](/blog/<slug>/images/diagram.svg).

How It Works

  • Schema: the blog collection in web/next/source.config.ts requires createdAt (and accepts optional updatedAt/publishedAt/draft/author/tags), so every post is validated at build time. Running bun .github/scripts/docs.ts adds a missing createdAt with 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.ts defines the shared rule for published posts: not draft and publishedAt is present and not future-dated. web/next/src/lib/blog.ts adapts that rule to Fumadocs pages for the blog routes, listing, sitemap, llms.txt, and OG image routes.
  • Listing: /blog renders the <BlogPostList /> component (web/next/src/components/blog/post-list.tsx), which displays published posts newest-first by publishedAt, and renders "No posts published yet." when the published set is empty. web/next/content/blog/index.mdx only 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.json is generated by the docs generator (.github/scripts/docs.ts, generateBlogMeta) from posts published at generation time (index first, then newest-first by publishedAt) 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; /blog is the canonical public listing.
  • updatedAt: optional updatedAt values are validated like createdAt and are used as the blog page lastModified value in sitemap.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 },
  },
})