Astro SEO starts with an advantage most site builders do not have. Astro sends finished HTML to the browser, so Google reads every word of a page without running any JavaScript first.
contextbolt.com, the home of ContextBolt, is an Astro site of about 650 pages on Cloudflare Pages. The whole thing runs on Astro, three official Astro integrations and three packages that draw our share images. No React, no Vue, no SEO plugin.
That still leaves a lot Astro will not do for you. It will not write a title, pick a canonical, date your sitemap or send a 301. This is the setup we run for each one, with the code, and the mistakes that taught us.
- Astro is good for SEO. Pages ship as plain HTML with no JavaScript unless you add it, so Google reads the content on the first pass.
- The SEO decisions are still yours. One Head component handles titles, descriptions and canonicals for every page.
- The sitemap needs real dates.
@astrojs/sitemapcannot read your pages, so each page’s date comes from your content throughserialize(). - Redirects belong on the host. Astro’s built-in redirects become meta refresh pages on a static site. A
_redirectsfile sends a real 301. - Make the build check it. Ours fails on a 61-character title. ContextBolt SEO then checks rankings from the same Claude Code window the site is built in.
Is Astro good for SEO?
Yes, and the reason is simple. A React app built the old way sends the browser an empty page and a bundle of JavaScript. The words appear once the script runs.
Astro does the opposite. It renders every page to HTML at build time and ships zero JavaScript unless you ask for it. The interactive exceptions are what Astro calls islands, small components that load their own script.
Google can run JavaScript. Its JavaScript SEO guide describes three phases, crawling, rendering and indexing, and says a page may wait in the render queue “for a few seconds, but it can take longer than that.” An HTML-first page does not need that step for its words.
Other crawlers matter too. Vercel tested the big AI crawlers in December 2024 and found that “none of the major AI crawlers currently render JavaScript.” An Astro page looks the same to them as it does to Google.
Our site takes this further than most. Search the codebase for client:, the directive that turns a component into an island, and you find none. Every interactive piece is plain script inside an Astro component. We measured what that saves against Next.js in Astro vs Next.js.
What Astro does not do is make SEO decisions. It does not know your title is too long, that two addresses show the same page, or that you renamed a post last week. The rest of this guide is those decisions.
One Head component for titles, descriptions and canonicals
Put every SEO tag in one component and call it from your base layout. Each page passes its own title and description, and nothing gets forgotten on page 400. If you are coming from WordPress, this is the job your SEO plugin used to do, and Astro vs WordPress covers the rest of that switch.
Here is a trimmed version of the pattern:
---
// src/components/Head.astro
interface Props {
title: string;
description: string;
noindex?: boolean;
}
const { title, description, noindex = false } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<title>{title}</title>
<meta name="description" content={description} />
<meta name="robots" content={noindex ? 'noindex' : 'index, follow'} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
Astro’s own blog starter builds the canonical the same way, from Astro.url.pathname and Astro.site. That only works if site is set in astro.config.mjs, which the sitemap needs too. Our real component takes the canonical as a prop instead, so a page can point it somewhere else when it has to.
Pick one URL form and keep it: Our config sets build.format: 'directory', so every page is a folder with an index.html. Cloudflare Pages then sends /blog/seo-retainer to /blog/seo-retainer/ with a 308, a permanent redirect. Every canonical, sitemap entry and internal link uses the slash form, so Google never meets two addresses for one page. When it does, you get the mess in Duplicate, Google chose different canonical.
Titles that fit: Search results cut long titles, and around 60 characters is the safe line. Our blog layout adds ” | ContextBolt” only when the whole title still fits in 60, and drops it when it would not. Descriptions run 120 to 155 characters with the main phrase first.
A sitemap with real dates
Install @astrojs/sitemap, set site, and every build writes sitemap-index.xml and sitemap-0.xml. The integration docs are short. Two things the default will not do are worth fixing on day one.
It includes every page: That means thank-you pages, admin screens and anything you marked noindex. A noindex page in a sitemap is a mixed message. The sitemap says “index this” and the page says “don’t.” Filter them out.
It has no per-page dates: The docs say the integration “can’t analyze a given page’s source code,” so its lastmod option sets one date for the whole site. Per-page dates go in serialize(). Google’s sitemap guide says it uses lastmod “if it’s consistently and verifiably” accurate, and ignores priority and changefreq entirely. So skip those two and get the dates right.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
// URL -> date, read from each post's `modified` frontmatter field
const lastmod = await buildLastmodMap();
export default defineConfig({
site: 'https://example.com',
integrations: [
sitemap({
filter: (page) => !page.includes('/thank-you/'),
serialize(item) {
const date = lastmod.get(item.url.replace(/\/$/, ''));
if (date) item.lastmod = date;
return item;
},
}),
],
});
Our buildLastmodMap() reads the modified field from every content file, so editing a post moves its date. Nobody has to remember.
Hub pages taught us the second half. We once hardcoded the date for /blog/. It sat at April 10 through an August when we shipped dozens of posts, telling Google the busiest page on the site had not changed in four months. Now a hub’s date is the newest date of the pages it lists, worked out at build time.
robots.txt is one file
Put it in public/. Astro copies that folder to the root of the built site untouched. Ours is three lines:
User-agent: *
Allow: /
Sitemap: https://contextbolt.com/sitemap-index.xml
Point it at the index, not sitemap-0.xml. The integration’s default limit is 45,000 URLs per file, and past that it adds more numbered sitemaps. The index always lists them all.
If you want to keep AI crawlers out, robots.txt is where you say so. Ours lets them in, and the trade-offs are in should you block AI crawlers.
Structured data with a JsonLd component
Structured data is a block of JSON that tells search engines what a page is, like an article with an author and a date. In Astro it is a two-line component:
---
// src/components/JsonLd.astro
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />
set:html drops the JSON into the tag untouched. Our blog layout builds up to five types from each post’s frontmatter: BlogPosting, BreadcrumbList, FAQPage, HowTo and ItemList. The author writes the FAQ once in frontmatter, and it appears on the page and in the markup from the same source.
Set your expectations, though. Google stopped showing HowTo rich results in September 2023, and now shows FAQ rich results only for “well-known, authoritative government and health websites.” The same post says unused markup “does not cause problems for Search,” so we kept ours. It costs nothing and describes the page to any machine that reads it. Just do not add it hoping for stars in the results.
Redirects: use the host, not astro.config
Astro has a redirects option in astro.config.mjs. Read the small print first. On a static site with no adapter, the configuration reference says it “will produce a client redirect using a <meta http-equiv="refresh"> tag” and “does not support status codes.”
That is not a disaster for Google. Its redirect guide says it reads an instant meta refresh as permanent. But the same guide recommends “a permanent server-side redirect whenever possible,” and other crawlers and link tools treat a meta refresh page as a normal page.
So use the host. On Cloudflare Pages and Netlify that is a _redirects file in public/:
# public/_redirects
/blog/old-post/ /blog/new-post/ 301
/blog/old-post /blog/new-post/ 301
Always write the 301: Cloudflare’s redirects docs say a line with no code defaults to 302, a temporary redirect. We list both the slash and no-slash form of an old URL, and write a comment above the rules saying why they exist. When a page earns traffic for months after it moves, that comment stops the next person from deleting the rule.
Some 404s are worth catching with code: Several of our list posts have a number in the title but not the slug. “7 Best Self-Hosted Bookmark Managers” lives at /blog/best-self-hosted-bookmark-managers/. Anyone who turns the headline into a URL, AI assistants most of all, lands on /blog/7-best-self-hosted-bookmark-managers/. A small Cloudflare Pages Function catches that pattern and sends a 301 to the real post, so no rule per post is needed.
Speed: three settings we changed
Astro is fast by default. These three changes are what we added on top.
Inline the CSS: build.inlineStylesheets defaults to 'auto', which inlines only stylesheets under 4 KB. We set it to 'always', so every page carries its own styles and the browser never waits on a stylesheet request before it can paint. The trade-off is that styles are not cached between pages. For text-heavy pages read one at a time, it is worth it.
Load analytics after the page: Our Google Analytics tag waits for requestIdleCallback, with a four-second timeout, before it loads. Events still queue from the first moment, so nothing is lost, but the script never competes with the largest paint. If that is your weak spot, see LCP issue: longer than 2.5s on mobile.
Draw share images at build time: A script runs before astro build and draws every Open Graph card with Satori and Sharp. Each post gets its own card from its title, and no image service runs when someone shares a link.
If your pages are image-heavy, Astro’s Image component from astro:assets sets width and height for you, which stops the layout jumping as images load. Ours are mostly text and SVG, so we have not needed it.
Make the build check your SEO
Hand-checking does not last. Ours failed in July 2026, when 61 to 65 character titles shipped past a list someone had checked by eye.
Now a script reads every title and description, measures them, and exits with an error when one is too long. It also reads the built HTML and checks the structured data on every post. A separate check runs inside npm run build and fails it if any “related posts” link points at a page that no longer exists.
The core of the title check is three lines:
// scripts/seo-check.mjs (trimmed)
if (title.length > 60) {
issues.push(`title ${title.length}>60 (truncates in SERP)`);
errors++;
}
You do not need ours. Ask the AI that builds your site to write one for your rules, then wire it into the build. It pays for itself the first time it fails.
Check rankings from the same window
Most Astro sites are now built inside an AI coding tool, and ours is built in Claude Code. The SEO research belongs there too, next to the files it is about, so a finding can turn into an edit you review in the same window.
ContextBolt SEO is the SEO toolkit we build for this. It runs inside Claude Code over MCP, the open standard for plugging live tools into an agent. It brings keyword data, the results page for any search, page audits, and your own Google Search Console. Add it with one command:
claude mcp add --transport http contextbolt-seo \
https://seo.contextbolt.app/mcp
Open /mcp in Claude Code to sign in. The full setup is in SEO inside Claude Code. Then ask about the site you have open.
Copy this prompt
Pull my Search Console pages for the last 28 days. Find pages in
positions 8 to 20 that get impressions but few clicks. For each one,
open its .astro or .mdx file in this repo and suggest a better title
and description. Show me the changes before you edit anything.
Search Console calls are free. A keyword lookup costs one credit. We ran one on “astro seo” for this post on October 19, 2026. It came back at 110 searches a month in the US, difficulty 21 out of 100. The top ten was an npm package, a Reddit thread, Medium and a handful of small blogs, which is why this guide exists.
Your research saves to your private SEO Dashboard on its own. ContextBolt SEO is $35 a month after a 7-day free trial with 100 credits.