How to add RSS, a sitemap, and SEO metadata to Astro with a reusable BaseHead
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.
// 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.
// 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:
// 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 constCentralizing 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.
---
// 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:
<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:
{
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:
<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
siteoption inastro.config.mjsis required for both the sitemap and for building absolute canonical/OG URLs — set it first. @astrojs/rssis just aGEThandler insrc/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.tsso the feed, sitemap, and meta tags never disagree. - One
BaseHeadcomponent 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.