Lesson 6 · Core course

Add feeds, metadata, and release checks

readers can subscribe, search engines receive useful metadata, and one command checks the public build.

Sign in to save progress across devices

On this page

1. Install RSS and sitemap support

pnpm add --save-exact @astrojs/rss@4.0.19 @astrojs/sitemap@3.7.4

Replace astro.config.mjs, retaining your real domain:

File: astro.config.mjs

import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com',
  output: 'static',
  trailingSlash: 'always',
  integrations: [sitemap({
    filter: (page) => !page.endsWith('/404/') && !page.endsWith('/404.html'),
  })],
});

The sitemap describes generated pages. Production draft routes never exist, so the integration cannot include them. The custom 404 is deliberately excluded. See Astro sitemap for further configuration.

Create the RSS endpoint; it uses the same publication function as the site:

File: src/pages/rss.xml.js

import rss from '@astrojs/rss';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
import { getPosts, postHref } from '../lib/posts';

export async function GET(context) {
  return rss({
    title: SITE_TITLE,
    description: SITE_DESCRIPTION,
    site: context.site,
    items: (await getPosts()).map((post) => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.pubDate,
      categories: post.data.tags,
      link: postHref(post.id),
    })),
    customData: '<language>en</language>',
  });
}

This feed includes summaries and links, not full article bodies. The Astro RSS recipe explains additional options.

Create public/robots.txt, replacing the domain:

File: public/robots.txt

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml

robots.txt is guidance to cooperative crawlers, not a privacy control. public/ is copied as-is; never store private material there.

2. Finish the shared head

Replace the layout again. This final version preserves appearance behavior and adds feed discovery and social metadata:

File: src/layouts/Page.astro

---
import '../styles/global.css';
import '../styles/print.css';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
import ThemePicker from '../components/ThemePicker.astro';
import themeScript from '../scripts/theme.js?raw';

interface Props { title?: string; description?: string; noindex?: boolean; }
const { title = SITE_TITLE, description = SITE_DESCRIPTION, noindex = false } = Astro.props;
const pageTitle = title === SITE_TITLE ? title : `${title} · ${SITE_TITLE}`;
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="color-scheme" content="light dark" />
    <script is:inline set:html={themeScript}></script>
    <title>{pageTitle}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonical} />
    <link rel="sitemap" href="/sitemap-index.xml" />
    <link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href="/rss.xml" />
    <meta property="og:title" content={pageTitle} />
    <meta property="og:description" content={description} />
    <meta property="og:url" content={canonical} />
    <meta property="og:site_name" content={SITE_TITLE} />
    <meta property="og:type" content="website" />
    {noindex && <meta name="robots" content="noindex, nofollow" />}
  </head>
  <body>
    <a class="skip-link" href="#main">Skip to content</a>
    <header class="shell site-header">
      <a class="site-name" href="/">{SITE_TITLE}</a>
      <nav aria-label="Main navigation">
        <a href="/">Home</a><a href="/blog/">Writing</a><a href="/about/">About</a>
      </nav>
      <ThemePicker />
    </header>
    <main id="main" class="shell" tabindex="-1"><slot /></main>
    <footer class="shell site-footer">Written with curiosity. <a href="/rss.xml">RSS</a></footer>
  </body>
</html>

This supplies useful baseline metadata. A share image and og:type="article" with publication metadata are optional refinements after the course. Metadata helps readers and crawlers interpret your pages; it does not guarantee rankings.

3. Test the publication rule

Create a test that checks behavior rather than page styling:

File: src/lib/publication.test.ts

import test from 'node:test';
import assert from 'node:assert/strict';
import { isPublished } from './publication.ts';

const now = new Date('2026-10-06T12:00:00Z');
test('a past post needs explicit publication', () => {
  const pubDate = new Date('2026-10-01T00:00:00Z');
  assert.equal(isPublished({ draft: true, pubDate }, now), false);
  assert.equal(isPublished({ draft: false, pubDate }, now), true);
});
test('a future post is absent until a later build', () => {
  const pubDate = new Date('2026-10-07T00:00:00Z');
  assert.equal(isPublished({ draft: false, pubDate }, now), false);
  assert.equal(isPublished({ draft: false, pubDate }, pubDate), true);
});
test('an invalid date cannot pass the publication rule', () => {
  assert.equal(isPublished({ draft: false, pubDate: new Date('invalid') }, now), false);
});

Node 24 can run this small TypeScript test directly. Astro’s content schema separately rejects malformed frontmatter during the build.

4. Check the generated site

Create scripts/check-build.mjs:

File: scripts/check-build.mjs

import assert from 'node:assert/strict';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';

const root = resolve('dist');
for (const path of ['index.html', 'blog/index.html', 'about/index.html', '404.html', 'rss.xml', 'sitemap-index.xml', 'robots.txt']) {
  assert.ok(existsSync(join(root, path)), `Missing output: ${path}`);
}
const files = readdirSync(root, { recursive: true }).filter((name) => /\.(html|xml)$/.test(name));
let references = 0;
for (const name of files) {
  const body = readFileSync(join(root, name), 'utf8');
  assert.ok(!body.includes('COURSE_PRIVATE_DRAFT_MARKER'), `Draft leaked into ${name}`);
  assert.ok(!body.includes('COURSE_FUTURE_POST_MARKER'), `Future post leaked into ${name}`);
  if (name.endsWith('.html')) {
    assert.match(body, /<html[^>]+lang="en"/, `Missing language: ${name}`);
    assert.match(body, /<title>[^<]+<\/title>/, `Missing title: ${name}`);
    const earlyScript = body.indexOf("const key = 'my-blog-theme'");
    assert.ok(earlyScript >= 0 && earlyScript < body.indexOf('</head>'), `Missing early theme script: ${name}`);
  }
  for (const match of body.matchAll(/(?:href|src)="(\/[^"\s]*)"/g)) {
    const target = match[1].split(/[?#]/)[0];
    if (target.startsWith('//')) continue;
    const path = join(root, decodeURIComponent(target));
    assert.ok(existsSync(path) || existsSync(join(path, 'index.html')), `Broken link in ${name}: ${target}`);
    references++;
  }
}
for (const id of ['private-draft', 'future-note']) {
  assert.ok(!existsSync(join(root, 'blog', id, 'index.html')), `Unpublished route exists: ${id}`);
}
console.log(`Checked ${files.length} HTML/XML files and ${references} internal references.`);

The two practice posts are privacy fixtures. Keep them unpublished or remove them. If you want to publish their text later, create a real post and replace these fixtures/tests deliberately. The internal-reference check finds missing files; it does not validate external URLs or in-page anchor IDs.

Add the final test and verify scripts to package.json. This complete version also includes the deployment tool used in Lesson 08:

File: package.json

{
  "name": "my-blog",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@11.19.0",
  "engines": { "node": ">=24 <25" },
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "check": "astro check",
    "test": "node --test src/lib/publication.test.ts",
    "verify": "pnpm build && pnpm check && pnpm test && node scripts/check-build.mjs",
    "deploy:dry-run": "pnpm build && wrangler deploy --dry-run",
    "deploy": "pnpm verify && wrangler deploy",
    "astro": "astro"
  },
  "dependencies": {
    "astro": "7.3.3",
    "@astrojs/rss": "4.0.19",
    "@astrojs/sitemap": "3.7.4"
  },
  "devDependencies": {
    "@astrojs/check": "0.9.10",
    "typescript": "6.0.3",
    "wrangler": "4.134.0"
  }
}
pnpm install
pnpm verify

Checkpoint

The tests pass, Astro reports no errors, and the output checker finds no draft/future content or missing internal files. Open /rss.xml and /sitemap-index.xml in the built preview. The feed includes the public article and excludes both practice posts. The sitemap’s linked XML includes the article too.

Temporarily remove a required frontmatter field from your local practice post. The next build should fail with a useful validation error. Restore the field and rerun verification before continuing.