Astro Blog SEO Checklist for Content Collections
A practical Astro blog SEO checklist: the site setting, absolute canonicals, trailing slashes, @astrojs/sitemap, content collection schemas, BlogPosting JSON-LD, RSS, drafts and a post-deploy curl check, with code for current Astro.
Blog on Astro? See how Blogger Dog publishes into Astro.
Why Astro blogs need a checklist at all
Astro is one of the friendliest frameworks for SEO out of the box. Pages are rendered to static HTML by default, there is no client-side router hiding links from crawlers, and a Markdown post in a content collection turns into a fast page with almost no JavaScript. That is a better starting point than most stacks.
The problems show up in the details Astro leaves to you. The site setting is optional, so plenty of blogs ship without it and quietly lose their sitemap and absolute canonical URLs. Trailing slashes behave differently in dev and in the build. A sitemap integration is one command away, but it will happily list draft and tag pages you never meant to index. And a frontmatter typo can publish a post with no description at all.
This checklist covers the setup we would want on any Astro blog that is meant to bring in search traffic. The code targets the current Astro content collections API (src/content.config.ts with the glob() loader). If your project still uses src/content/config.ts and entry.slug from Astro 2 to 4, the ideas are identical; only the imports differ. If you run Next.js instead, the companion Next.js blog SEO checklist covers the same ground for the App Router.
1. Set site before anything else
The single most important line in an Astro SEO setup is the site option in astro.config.mjs. The Astro configuration reference describes it as your final deployed URL, which Astro uses to generate your sitemap and canonical URLs. Without it, Astro.site is undefined, the sitemap integration has nothing to build absolute URLs from, and the RSS helper cannot resolve links.
// astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://example.com",
trailingSlash: "always",
integrations: [sitemap()],
});
Use the exact production origin: https, the host you actually serve (with or without www), and no path unless the whole site lives under one. If your blog is served from a subpath, use the base option for the path and keep site as the origin.
2. Pick one trailing-slash style and make dev match the build
Astro's default trailingSlash is 'ignore', which means /blog/my-post and /blog/my-post/ both work. That is convenient, and it is also how one post ends up reachable at two URLs.
The configuration reference explains the other half: with the default build.format: 'directory', every page is built as /blog/my-post/index.html, and Astro.url.pathname includes a trailing slash during the build. Astro's own advice is to pair trailingSlash: 'always' with the directory format, or trailingSlash: 'never' with the file format, so dev and production agree.
Our recommendation for most blogs is the default directory format plus trailingSlash: "always", as in the config above. Then make your internal links match: /blog/my-post/, not /blog/my-post. Your host also has an opinion here (Netlify, Vercel and Cloudflare Pages each normalise slashes in their own way), so check the live behaviour with curl in step 10 rather than assuming.
3. Emit an absolute, self-referencing canonical on every page
Google's guide to consolidating duplicate URLs supports relative canonical URLs but recommends absolute ones, and recommends a self-referential canonical on the preferred page too. In Astro, build it once in the shared layout from Astro.url and Astro.site:
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description: string;
image?: string;
}
const { title, description, image = "/og-default.png" } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site);
const ogImage = new URL(image, Astro.site);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<slot />
</body>
</html>
Two things to avoid. Do not hard-code the homepage URL as the canonical in a layout that every page shares; that tells search engines your posts are duplicates of the homepage. And do not pass query strings into the canonical: Astro.url.pathname already drops them, which is what you want for tracking parameters.
Use a PNG or JPEG for og:image, around 1200 by 630 pixels. SVG social images look fine in your editor and render as nothing on most social platforms.
4. Validate frontmatter with a content collection schema
A content collection schema is an SEO tool in disguise. If every post must have a title, a description and a publish date, no post can ship without a meta description or with a date the sitemap and feed cannot read. The content collections guide shows the current pattern: a glob() loader from astro/loaders and a schema built with z from astro/zod.
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: z.string().max(70),
description: z.string().min(50).max(160),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
image: z.string().optional(),
}),
});
export const collections = { blog };
The length limits are not rules Google enforces; they are guardrails that keep titles from being truncated and stop one-line descriptions from slipping through. A build that fails on a missing description is far cheaper than a post that ranks with an auto-generated snippet.
Each entry gets an id derived from its filename, and the docs note you can override it with a slug field in frontmatter. Treat that id as permanent once a post is published. Renaming the file changes the URL, so if you must rename, add a redirect in astro.config.mjs under redirects.
5. Generate post routes from the collection and filter drafts
The post route reads the collection, filters out drafts, and renders each entry. This is the documented getStaticPaths pattern with the draft filter added:
---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout title={post.data.title} description={post.data.description} image={post.data.image}>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString("en-US", { dateStyle: "long" })}
</time>
<Content />
</article>
</BaseLayout>
Use the same filter everywhere you list posts: the blog index, tag pages, the RSS feed and any "related posts" component. A draft that is excluded from its own route but still linked from the index is a guaranteed 404 in Search Console.
Notice the single <h1> in the layout. If your Markdown posts also start with # Title, you get two H1s per page. Pick one source: either the frontmatter title is the H1 and the body starts at ##, or the reverse.
6. Add the sitemap integration, and filter what does not belong
The @astrojs/sitemap guide installs it with npx astro add sitemap. On build it writes sitemap-index.xml, which points at numbered files such as sitemap-0.xml that list your pages. Static routes from getStaticPaths are included; the docs state that the integration cannot generate entries for dynamic routes in SSR mode, so on-demand rendered pages need another approach.
By default every built page goes in. Use filter to drop pages you do not want indexed:
sitemap({
filter: (page) =>
!page.includes("/tags/") &&
!page.includes("/drafts/") &&
!page.endsWith("/thanks/"),
}),
Two cautions from experience. First, the integration accepts a site-wide lastmod, but one date for every URL tells crawlers every page changed on the same day, which is false; leave it out unless you compute real per-post dates in serialize. Second, the docs say Google ignores changefreq and priority, so do not spend time tuning them.
Finally, point crawlers at the index file from public/robots.txt:
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap-index.xml
Then submit the same sitemap-index.xml URL in Search Console. Submitting sitemap-0.xml instead works until the site grows past one file.
7. Add BlogPosting structured data
Structured data does not make a post rank, but it removes guesswork about the headline, dates, author and image. In Astro, the clean way to inline JSON-LD is the set:html directive, which writes the string without escaping it:
---
const { post } = Astro.props;
const url = new URL(Astro.url.pathname, Astro.site);
const schema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.data.title,
description: post.data.description,
datePublished: post.data.pubDate.toISOString(),
dateModified: (post.data.updatedDate ?? post.data.pubDate).toISOString(),
mainEntityOfPage: url.href,
image: new URL(post.data.image ?? "/og-default.png", Astro.site).href,
author: { "@type": "Organization", name: "Example" },
};
const json = JSON.stringify(schema).replace(/</g, "\\u003c");
---
<script type="application/ld+json" set:html={json} />
The replace call matters. A title or description containing </script> would otherwise close the tag early. Escaping < keeps the JSON valid and the page safe. Use an author Person with a real name if a real person wrote the post; use Organization if it is a team byline.
8. Ship an RSS feed from the same collection
RSS is not a ranking factor, but feeds are how readers, newsletter tools and some aggregators find new posts, and every one of those can turn into a link. The Astro RSS guide uses @astrojs/rss and context.site, which is another reason step 1 matters:
// src/pages/rss.xml.js
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
export async function GET(context) {
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({
title: "Example Blog",
description: "Notes on building Example",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.id}/`,
})),
});
}
The collections guide notes that getCollection does not guarantee order, so sort explicitly, as above. Link the feed from your layout's <head> with <link rel="alternate" type="application/rss+xml" href="/rss.xml" />.
If you also want AI assistants to find a clean index of your posts, the same collection can generate an llms.txt file; our guide to llms.txt for developer blogs includes an Astro endpoint for it.
9. Make every post reachable through plain links
Search engines discover posts by following <a href> links in the HTML. Astro renders those by default, which is a real advantage, but a few patterns undo it:
- A blog index that only shows the latest ten posts with a "Load more" button. Use paginated pages (
/blog/2/,/blog/3/) built with Astro'spaginate()instead of a client-side loader. - Navigation rendered inside a framework island with
client:only. Content inside aclient:onlycomponent is not in the server HTML. Keep header and footer links in.astrocomponents. - Posts that never link to each other. Add two or three in-body links per post to related posts and to your product pages. Internal links are how a new post gets crawled quickly and how older posts keep receiving authority.
A quick test: run npm run build and grep the output for a post URL. If grep -rl "/blog/my-post/" dist/ returns only the post itself, nothing links to it.
10. Check the live result, not the build
Every step above can be correct in the repo and wrong in production: the host rewrites slashes, a redirect rule shadows a route, or the deploy simply failed. After each deploy, check one post with curl:
URL="https://example.com/blog/my-post/"
curl -sI "$URL" | head -1
curl -s "$URL" | grep -o '<link rel="canonical"[^>]*>'
curl -s "$URL" | grep -c '<h1'
curl -s https://example.com/sitemap-0.xml | grep -c "$URL"
curl -sI "${URL%/}" | grep -iE '^(HTTP|location)'
You want a 200, a canonical that equals the URL exactly, one H1, the URL present in the sitemap, and the slashless variant either redirecting to the slashed one or returning a 404, never a second 200. This live check is the same idea behind live URL verification for GitHub blog publishing: a post only counts once the public page proves it.
The checklist in one place
siteset to the exact production origin inastro.config.mjs.trailingSlashset to matchbuild.format, internal links in the same style.- Absolute, self-referencing canonical and per-page Open Graph tags from the shared layout.
- PNG or JPEG social images, around 1200 by 630.
- Content collection schema requiring title, description and date.
- Drafts filtered with the same predicate everywhere posts are listed.
- One H1 per post.
@astrojs/sitemapwith afilter, no fake globallastmod, robots.txt pointing atsitemap-index.xml.- BlogPosting JSON-LD with
<escaped. - RSS feed from the same collection, sorted by date.
- Paginated, crawlable index pages and two or three in-body internal links per post.
- A curl check against production after every deploy.
FAQ
Is Astro good for SEO?
Yes. Static HTML by default, minimal JavaScript and plain links give crawlers exactly what they need. The gaps are configuration choices Astro leaves to you, mainly site, trailing slashes, canonicals and sitemap filtering, which is what this checklist covers.
Do I need @astrojs/sitemap if I already submit URLs in Search Console?
Yes. Manual indexing requests are rate-limited and one-off. A sitemap lets Google find every new post on its own, and Google's duplicate-URL guide describes listing a URL in a sitemap as a weak signal that it is the canonical version.
Should my Astro blog URLs end with a slash?
Either works. What matters is consistency: one style in trailingSlash, the same style in internal links, the canonical and the sitemap, and a redirect or 404 for the other variant.
Markdown or MDX for SEO?
Search engines see the same HTML either way. Use Markdown for plain posts and MDX only when a post needs components. Plain Markdown is also easier for tools, and for other people, to write into your repo.
Where should posts live in an Astro repo?
Anywhere a glob() loader can read. src/content/blog/ is the common convention. Our Astro integration page explains how Blogger Dog detects that folder and writes posts into it.
Keep the posts coming
A well-configured Astro blog with no new posts is still a quiet blog. Blogger Dog researches and writes cited SEO and AEO posts, commits them into your Astro repo as Markdown or MDX with title, date, description, tags and slug frontmatter (if your schema calls the date pubDate, accept date as well), and only counts a post once its public URL is live. It works the same way for Next.js, Hugo, Jekyll, Gatsby and Docusaurus, on GitHub or GitLab.
You get 3 posts free, then plans start at $19/month. See pricing and plans.