ehsan.blog
~/blog/how-to-add-rss-sitemap-and-seo-meta-to-astro — zsh
cat how-to-add-rss-sitemap-and-seo-meta-to-astro.md

How to add RSS, a sitemap, and SEO metadata to Astro with a reusable BaseHead

·6 min read

When I put my blog online, three things had to work before search engines and readers could find it: an RSS feed so people can subscribe, a sitemap so crawlers can discover every page, and consistent <head> metadata (title, description, social cards) on every page. Doing this by hand on each page is how you end up with a missing canonical tag on the one page that matters. So I built it once, properly. Here is exactly how.

We will cover the three pieces in order. Everything below is the real code from this blog.

1. A sitemap with @astrojs/sitemap

A sitemap is an XML file listing every URL on your site so crawlers do not have to guess. Astro has an official integration that generates it at build time — you just add it to your config.

mjs
// blog/astro.config.mjs
import { defineConfig } from "astro/config"
import sitemap from "@astrojs/sitemap"

export default defineConfig({
  site: "https://blog.developerehsan.com",
  trailingSlash: "ignore",
  integrations: [
    sitemap({
      changefreq: "weekly",
      priority: 0.7,
      lastmod: new Date(),
    }),
  ],
})

Two things matter here. First, the top-level site option is required for the sitemap to work — the integration needs an absolute base URL to build absolute links, and it also feeds the canonical URLs we build later. Second, changefreq, priority, and lastmod are hints for crawlers about how often pages change and how important they are.

Run astro build and you get sitemap-index.xml plus a sitemap-0.xml in your output. The index file is the one you point crawlers at.

2. An RSS feed with @astrojs/rss

RSS is a plain XML format that lets readers subscribe to your posts in a feed reader. In Astro, a feed is just a file at src/pages/rss.xml.js that exports a GET function — Astro turns that into a /rss.xml route.

js
// blog/src/pages/rss.xml.js
import rss from "@astrojs/rss"
import { getCollection } from "astro:content"
import { SITE } from "@/consts"

export async function GET(context) {
  const posts = (
    await getCollection("posts", ({ data }) => data.draft !== true)
  ).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())

  return rss({
    title: SITE.title,
    description: SITE.description,
    site: context.site ?? SITE.url,
    items: posts.map((post) => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.date,
      link: `/${post.id}`,
      categories: post.data.tags,
    })),
    customData: `<language>en-us</language>`,
  })
}

Walking through it: getCollection("posts", ...) loads every post from the typed content collection, and the filter data.draft !== true keeps unpublished drafts out of the feed. I sort newest-first by comparing the date values. Then I map each post into the shape @astrojs/rss expects — title, description, pubDate, a link, and categories (I reuse the post’s tags). The customData string is raw XML injected into the channel; here it just declares the feed language.

context.site ?? SITE.url uses the site from your Astro config, falling back to a constant if it is somehow missing. That constant lives in one place:

ts
// blog/src/consts.ts
export const SITE = {
  title: "Ehsan Shahid — Blog",
  description:
    "Notes on full stack development by Ehsan Shahid — React, Next.js, Astro, Node.js, TypeScript, auth, DevOps, and lessons from shipping production software.",
  url: "https://blog.developerehsan.com",
  author: "Ehsan Shahid",
  ogImage: "/og-image.png",
} as const

Centralizing this means the RSS feed, the sitemap’s canonical URLs, and the metadata below all agree on one title, one description, one base URL.

3. One BaseHead for all your meta tags

The last piece is the <head>. Every page needs a title, a description, a canonical URL (the “official” URL for this page so duplicate paths do not split your ranking), Open Graph tags (what Facebook/LinkedIn show when someone shares a link), Twitter Card tags, and a robots directive. Instead of repeating that on every page, I put it in one component.

astro
---
// blog/src/components/BaseHead.astro
import { SITE } from "@/consts"

interface Props {
  title: string
  description: string
  type?: "website" | "article"
  image?: string
  publishedTime?: Date
  modifiedTime?: Date
  tags?: string[]
}

const {
  title,
  description,
  type = "website",
  image = SITE.ogImage,
} = Astro.props

const canonical = new URL(Astro.url.pathname, SITE.url).href
const ogImageUrl = new URL(image, SITE.url).href
---

The frontmatter (the code between the --- fences) computes two absolute URLs. new URL(Astro.url.pathname, SITE.url) combines the current page’s path with the site’s base URL to produce the canonical link. The same trick turns a relative image path like /og-image.png into a full absolute URL, which social platforms require.

Then the component outputs the actual tags:

astro
<title>{title}</title>
<meta name="description" content={description} />
<meta name="author" content={SITE.author} />
<meta
  name="robots"
  content="index, follow, max-image-preview:large, max-snippet:-1"
/>
<link rel="canonical" href={canonical} />
<link
  rel="alternate"
  type="application/rss+xml"
  title={SITE.title}
  href={new URL("/rss.xml", SITE.url).href}
/>

<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />

A few things worth calling out. The robots value tells search engines to index the page and follow its links, and to allow large image previews and untruncated snippets in results. The rel="alternate" type="application/rss+xml" link is how browsers and readers auto-discover the feed we built in step 2. And twitter:card set to summary_large_image is what gives you the big preview image instead of a tiny thumbnail.

The type prop defaults to "website" but pages pass "article" for posts, which lets the component emit article-specific Open Graph tags. Here is the article branch — Astro only renders it when both conditions are true:

astro
{
  type === "article" && publishedTime && (
    <meta
      property="article:published_time"
      content={publishedTime.toISOString()}
    />
  )
}
{
  type === "article" &&
    tags.map((tag) => <meta property="article:tag" content={tag} />)
}

Putting it together

Any page or layout just renders <BaseHead> with a title and description and gets everything else for free:

astro
<BaseHead title="My post title" description="A short summary." type="article" />
flowchart LR
  C[consts.ts SITE] --> R[rss.xml.js feed]
  C --> B[BaseHead.astro meta]
  Cfg[astro.config sitemap] --> S[sitemap-index.xml]
  B --> Page[every page head]

What to remember

  • The site option in astro.config.mjs is required for both the sitemap and for building absolute canonical/OG URLs — set it first.
  • @astrojs/rss is just a GET handler in src/pages/rss.xml.js; filter out drafts and sort newest-first.
  • Build canonical and image URLs with new URL(path, SITE.url) so they are always absolute.
  • Keep title, description, and base URL in one consts.ts so the feed, sitemap, and meta tags never disagree.
  • One BaseHead component means every page ships correct SEO and social meta without copy-paste.

Meta tags are only the first layer of SEO. The next step is structured data: adding BlogPosting and BreadcrumbList JSON-LD to each post, and then unifying authorship across the blog and portfolio with a shared Person @id.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.