# Blog and feeds

> Write Markdown posts under src/content/blog/, see how they become pages, and what the two RSS feeds and the sitemap's blog chunk publish.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/configure/blog-and-feeds/

---

The blog is a folder of Markdown files, `src/content/blog/`, that `features.blog` — on by
default — turns into `/blog/` and one page per post. Two RSS feeds come with it, `/rss.xml` for
listings and `/blog/rss.xml` for posts, and the sitemap keeps the blog pages in a chunk of their
own. The free edition has all of it.

## Write a post

```md title="src/content/blog/my-first-post.md"
---
title: Why we started this directory
description: The gap we kept running into, and what this site does about it.
pubDate: 2026-09-14
tags: [editorial]
draft: false
---
The body is Markdown. Headings, lists, links, images and code blocks render with the site's prose styles.
```

The file name is the URL: `my-first-post.md` becomes `/blog/my-first-post/`. Keep posts at the top
level of the folder — a file in a subfolder gets an id with a slash in it, which the URL builder
refuses, and the build stops.

| Field | Required | Default | What it does |
| --- | --- | --- | --- |
| `title` | yes | — | the heading; the `<title>` through `seo.templates.blog.title` and `titleTemplate` |
| `description` | yes | — | the line under the heading, the meta description and the feed item's text |
| `pubDate` | yes | — | the date shown; posts sort newest first |
| `updatedDate` | no | — | shown as **Updated** when the day differs; `article:modified_time`, which the sitemap reads as `lastmod` |
| `cover` | no | — | a path or URL rendered at 1200×630 under the header and used as the `og:image`; not validated |
| `tags` | no | `[]` | chips on the card and the post; free-form, with no pages behind them |
| `draft` | no | `false` | hidden from the build and the feed; shown under `pnpm dev` with a **Draft** badge |

Leave out `title`, `description` or `pubDate` and the build stops with
`**blog → my-first-post** data does not match collection schema.` followed by one line per field.

## The index and the post page

`/blog/` is one list of every published post, newest first — no pagination and no tag pages. Each
card is the title, the description and the meta line (date, **Updated** date, tags); the header
carries an **RSS feed** button, and an empty folder shows `No posts yet — check back soon.`

![The demo's /blog/ index: the heading, the RSS feed button and the post cards](../../../assets/screenshots/blog-index.png)

A post page is the breadcrumb, the title, the description, the meta line, the cover when there is
one, and the body. It links its feed in the head, marks itself `og:type article` with published
and modified times, and emits one `BlogPosting` JSON-LD node. Posts are not in the site search;
only listing pages are indexed.

![One demo post: breadcrumb, title, description, meta line and body](../../../assets/screenshots/blog-post.png)

## Feeds

| Feed | URL | Items | Limit | Discoverable from |
| --- | --- | --- | --- | --- |
| Listings | `/rss.xml` | one per listing: name, tagline, `approvedAt`, link, category and tag names | the newest 50 by `approvedAt` | `<link rel="alternate">` in the head of every page, and the footer's **RSS feed** link |
| Blog | `/blog/rss.xml` | one per published post: title, description, `pubDate`, link, tags | none — every published post, newest first | `<link rel="alternate">` on `/blog/` and every post, and the **RSS feed** button on `/blog/` |

Both are RSS 2.0. The listings feed is titled with `site.name`, the blog feed `<site name> Blog`,
and both carry `<language>` from `site.locale`. Item links are absolute against `site.url`, which
is why the feeds point at the wrong domain until that field is yours. Items carry a description
only — the post body is not in the feed — and there is no Atom, JSON Feed or per-category feed.

## The sitemap

Blog pages land in `sitemap-blog-0.xml`, priority 0.5, changefreq monthly, with `lastmod` read
from each post's `article:modified_time`. That chunk is listed in `/sitemap-index.xml`, which the
head of every page and the `Sitemap:` line of `/robots.txt` point at. The two feeds are files, not
pages, so they are not in it; the rest is on
[Sitemap and robots](https://zerodirs.com/docs/configure/seo/#sitemap-and-robots).

## Replace the demo posts

```sh title="Terminal"
pnpm content:reset --yes
```

It deletes every `.md` in `src/content/blog/` except `unpublished-draft.md`, the `draft: true`
fixture, writes one `hello-world.md` addressed to you, and clears `src/content/listings/` and
`public/logos/` at the same time. The log ends with
`reset: kept src/content/blog/unpublished-draft.md (starter fixture, draft: true — delete it if you do not want it)`.
`pnpm content:use <dataset> --yes` is the reverse: it copies a dataset's `blog/` folder in, again
keeping the fixture.

> **The demo posts are not yours to publish**
>
> The posts that ship name real products — Grammarly, LanguageTool, DeepL Write and others — and
> exist to show a full site. They are not licensed for republishing as your own editorial; replace
> them before you go live.

## Verify

| | Where | You should see |
| --- | --- | --- |
| The index | `/blog/` | your posts newest first and the **RSS feed** button |
| A post | `/blog/<file name>/` | the title, description, date, tags and body |
| The blog feed | `/blog/rss.xml` | `<title><site name> Blog</title>` and one `<item>` per published post, every link starting with `site.url` |
| The listings feed | `/rss.xml` | `<title><site name></title>` and at most 50 items |
| The sitemap chunk | `/sitemap-blog-0.xml` | `/blog/` and one `<url>` per post with `<priority>0.5</priority>`; the build log reads `sitemap: N urls in M chunks (listings=…, blog=…)` |

## When it is off or degraded

| Config state | The visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `features.blog: false` | no **Blog** item in the nav; `/blog/`, the post pages and `/blog/rss.xml` are gone — the routes answer a body-less 204 at build, so nothing is written, and the deployed paths 404. `/rss.xml` is unaffected | nothing | set it back to `true` |
| A post with `draft: true` | not on `/blog/`, not built, not in the feed; under `pnpm dev` it shows with a **Draft** badge | nothing | `draft: false`, or delete the line |
| A post missing `title`, `description` or `pubDate` | no site: the build stops with `**blog → <file>** data does not match collection schema.` and names the field | nothing | add the field |
| A post in a subfolder | no site: the build stops with `paths: invalid path segment "<id>"` | nothing | move the file to the top level |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `features.blog` | `true` | the whole blog: the nav item, `/blog/`, the post pages, `/blog/rss.xml` and the sitemap chunk |
| `seo.templates.blog.title` | `'{title}'` | a post's `<title>`, before `titleTemplate` adds the site name |
| `seo.templates.blog.description` | `'{title} — from the {siteName} blog.'` | nothing today: the index and every post pass their own description over it |
| `site.locale` | `'en'` | `<language>` in both feeds and the date format |
| `site.url` | required | what makes feed links, canonicals and the sitemap absolute |

See [`features`](https://zerodirs.com/docs/configure/reference/#features), [`seo`](https://zerodirs.com/docs/configure/reference/#seo) and
[`site`](https://zerodirs.com/docs/configure/reference/#site).

## Files

- src/
  - content/blog/ the posts; `unpublished-draft.md` is the draft fixture
  - content/schema.ts `blogSchema`, the frontmatter rules above
  - pages/blog/ `/blog/`, `/blog/<slug>/` and `/blog/rss.xml`
  - pages/rss.xml.ts the listings feed and its item limit
  - templates/BlogIndexPage.astro the index
  - templates/BlogPostPage.astro one post
  - components/blog/ the post card, the meta line and `getPublishedPosts()`
- integrations/sitemap.ts the chunked sitemap and its build log line

## Related

- [Listings and datasets](https://zerodirs.com/docs/configure/data/) — The Markdown contract for listings and the datasets that ship with the starter.
- [SEO and the report](https://zerodirs.com/docs/configure/seo/) — Titles, descriptions, the sitemap and what the build report checks.
- [Make it yours](https://zerodirs.com/docs/start/make-it-yours/) — Name, domain, content and theme: the first hour with a fresh copy.
- [Newsletter](https://zerodirs.com/docs/grow/newsletter/) — The sign-up form in the footer and where the addresses go.
