Astro SEO comes down to a short list of things Astro doesn’t do for you: set site in astro.config.mjs, render a title, description, canonical URL and Open Graph tags on every page, generate a sitemap and robots.txt, add structured data, and keep URLs consistent. Astro already gives you the hard part, fast pages of complete HTML that crawlers can read without running JavaScript.
This checklist covers each item with code you can paste into an Astro 7 project, in the order you should do them.
Key Takeaways
- Set
sitefirst. The sitemap, canonical URLs, RSS and Open Graph URLs all depend on it.- Put every head tag in one
SEO.astrocomponent and pass page-specific values as props.npx astro add sitemapcreatessitemap-index.xmlon every build.- Add JSON-LD with
<script type="application/ld+json" set:html={JSON.stringify(schema)} />.- Pick one URL format (trailing slash or not) and make config, links and canonicals agree.
1. Set site in astro.config.mjs
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://www.example.com',
});
With site set, Astro.site and Astro.url produce absolute URLs, and integrations like the sitemap and RSS can build full links. Without it, the sitemap integration logs a warning and writes nothing. Use the exact production origin, with or without www, matching what you want indexed.
2. A reusable SEO head component
Every page needs a unique title and description, a canonical URL and social tags. Put them in one component:
---
// src/components/SEO.astro
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
}
const { title, description, image = '/og/default.jpg', type = 'website', noindex = false } = Astro.props;
const siteName = 'Example Studio';
const canonical = new URL(Astro.url.pathname, Astro.site);
const ogImage = new URL(image, Astro.site);
---
<title>{title === siteName ? title : `${title} | ${siteName}`}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, follow" />}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={siteName} />
<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" />
Use it in your base layout and pass values from each page:
---
// src/layouts/BaseLayout.astro
import SEO from '../components/SEO.astro';
const { title, description, image } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<SEO title={title} description={description} image={image} />
</head>
<body><slot /></body>
</html>
Guidelines for the values themselves:
- Title: under about 60 characters, primary keyword near the start, unique per page.
- Description: about 150 to 160 characters. Answer the query directly; search engines and AI assistants often quote it.
- Image: 1200 × 630 px for Open Graph. Make it absolute, as above.
Building canonicals from Astro.url.pathname drops query strings, so ?utm_source= variants point back at the clean URL. The canonical URLs tip covers edge cases like pagination and syndicated posts.
3. Sitemap
npx astro add sitemap
This installs @astrojs/sitemap and adds it to integrations. On the next build, Astro writes sitemap-index.xml and sitemap-0.xml to dist/, listing every static page including those from getStaticPaths().
Exclude pages that shouldn’t be indexed:
sitemap({
filter: (page) => !page.includes('/thank-you/') && !page.includes('/drafts/'),
})
The sitemap tip covers lastmod, splitting large sitemaps and on-demand routes. The official sitemap docs list every option.
4. robots.txt
For a fixed file, put it in public/:
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://www.example.com/sitemap-index.xml
To keep the domain in one place, generate it from an endpoint instead:
// src/pages/robots.txt.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ site }) => {
const sitemap = new URL('sitemap-index.xml', site).href;
return new Response(`User-agent: *\nAllow: /\n\nSitemap: ${sitemap}\n`);
};
robots.txt controls crawling, not indexing. To keep a page out of results, use the noindex meta tag from step 2 and leave the page crawlable. The robots.txt tip has examples for staging sites and AI crawlers.
5. Structured data with JSON-LD
Structured data tells search engines what a page is about: an article, a product, an organisation, an FAQ. Astro has no special API for it; render a script tag with set:html:
---
// src/components/ArticleSchema.astro
interface Props {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
author: string;
}
const { title, description, pubDate, updatedDate, author } = Astro.props;
const schema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description,
datePublished: pubDate.toISOString(),
dateModified: (updatedDate ?? pubDate).toISOString(),
author: { '@type': 'Person', name: author },
mainEntityOfPage: new URL(Astro.url.pathname, Astro.site).href,
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
set:html is required. Writing {JSON.stringify(schema)} as a child would escape the quotes and break the JSON.
Types worth adding on most sites:
| Page | Schema type |
|---|---|
| Home | Organization or Person, plus WebSite |
| Blog post | BlogPosting or Article |
| Product or theme page | Product with offers |
| FAQ section | FAQPage |
| Breadcrumb trail | BreadcrumbList |
Validate with Google’s Rich Results Test before shipping. Google’s structured data introduction lists which types are eligible for rich results.
6. RSS feed
RSS helps feed readers and is a clean machine-readable index of your content:
npm install @astrojs/rss
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = await getCollection('blog');
return rss({
title: 'Example Studio Blog',
description: 'Notes on building fast websites.',
site: context.site!,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/blog/${post.id}/`,
})),
});
}
Link it from your head so readers can discover it:
<link rel="alternate" type="application/rss+xml" title="Blog" href="/rss.xml" />
The RSS docs for Komal themes show how it’s wired in a theme, and the Astro RSS recipe covers full-content feeds.
7. Consistent URLs: trailing slashes and redirects
Search engines treat /about and /about/ as different URLs. Choose one and enforce it:
export default defineConfig({
trailingSlash: 'always',
build: { format: 'directory' },
});
Then write internal links the same way, make canonicals match, and set up redirects for any URL that moved. The trailing slash tip explains how trailingSlash and build.format interact with each host, and the redirects tip shows how to keep old URLs working.
8. Headings, links and content structure
These are not Astro-specific, but templates make them easy to get wrong across a whole site:
- One
h1per page. If your layout renders the title ash1, don’t start Markdown content with another#heading. - Logical heading levels.
h2for sections,h3inside them. Screen readers and crawlers both use the outline. - Descriptive link text. “Read the deploy guide” beats “click here”.
- Internal links. Link related pages to each other; orphan pages get crawled less.
- Alt text on every meaningful image, empty
alt=""on decorative ones.
9. Performance
Page experience signals, including Core Web Vitals, are part of how Google evaluates pages. Astro sites start well, but images, fonts and third-party scripts can undo that. The Astro performance guide covers each one.
10. Make pages citable by AI assistants
AI search tools pull answers from pages they can parse and trust. The same habits that help search snippets help here:
- Answer first. Put a one or two sentence direct answer at the top of the page.
- Clear structure. Descriptive headings, short sections, lists and tables for comparisons.
- Question headings. FAQ sections with real questions are easy to quote.
- Dates. Show
pubDateandupdatedDate, and keep the latter accurate. - An
llms.txtfile. A plain-text index at/llms.txtlisting your key pages with one-line summaries. It’s a proposed convention rather than a standard, but it’s cheap to add with an endpoint.
// src/pages/llms.txt.ts
import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('blog');
const lines = posts.map((p) => `- [${p.data.title}](https://www.example.com/blog/${p.id}/): ${p.data.description}`);
return new Response(`# Example Studio\n\n## Articles\n${lines.join('\n')}\n`);
}
Komal themes ship as static Astro 7 sites with layouts that render titles and descriptions per page, so you start from a crawlable baseline. Browse Astro themes by site type.
Open Graph images per page
A shared link with a specific image gets more clicks than one with a generic logo. You have three options, from simplest to most automated.
A default image in public/og/default.jpg, used by the head component when a page doesn’t set one. Every site should have this.
A frontmatter field in your content schema, so writers choose an image per post:
// src/content.config.ts
schema: ({ image }) => z.object({
title: z.string(),
ogImage: image().optional(),
}),
---
import { getImage } from 'astro:assets';
const og = post.data.ogImage
? (await getImage({ src: post.data.ogImage, width: 1200, height: 630, format: 'jpg' })).src
: '/og/default.jpg';
---
<SEO title={post.data.title} description={post.data.description} image={og} />
getImage() resizes and crops at build time. JPG is the safest format for social crawlers.
Generated images rendered from the post title at build time, using an endpoint such as src/pages/og/[slug].png.ts and a library that turns HTML or JSX into PNG. This gives every page a unique image with no design work per post, at the cost of a slower build and a dependency to maintain.
Test with each network’s preview tool after deploying; social platforms cache images aggressively, so get it right before sharing widely.
Multilingual SEO
If you use Astro’s i18n routing, tell search engines about alternate languages with hreflang links in the head:
---
import { getAbsoluteLocaleUrl } from 'astro:i18n';
const locales = ['en', 'fr', 'de'];
const path = Astro.url.pathname.replace(/^\/(fr|de)\//, '/');
---
{locales.map((locale) => (
<link rel="alternate" hreflang={locale} href={getAbsoluteLocaleUrl(locale, path.slice(1))} />
))}
<link rel="alternate" hreflang="x-default" href={getAbsoluteLocaleUrl('en', path.slice(1))} />
Every language version should list all versions, including itself. The sitemap integration can add the same information through its i18n option.
Common Astro SEO mistakes
site left as the starter’s example domain. Canonicals and the sitemap then point at someone else’s site. Check site before the first deploy.
Duplicate titles. A layout that renders the same <title> on every page because a prop wasn’t passed. Make title a required prop so astro check catches it.
Canonical built from Astro.url. It includes query strings and the preview host. Build from Astro.url.pathname and Astro.site.
Two H1s per post. The layout renders the title as h1, and the Markdown body starts with # Title. Remove the one in Markdown.
Client-only content. Text rendered by a client:only island isn’t in the HTML. Crawlers that don’t execute JavaScript won’t see it. Keep important content server-rendered.
Noindexed pages in the sitemap. Filter them out of @astrojs/sitemap so you aren’t asking search engines to crawl pages you’ve told them not to index.
The checklist
-
siteset to the production origin - Unique
<title>and meta description on every page - Canonical URL on every page, built from
Astro.site - Open Graph and Twitter tags with an absolute 1200 × 630 image
-
@astrojs/sitemapinstalled, private pages filtered out -
robots.txtwith aSitemap:line - JSON-LD for the organisation, articles and products
- RSS feed linked from
<head> - One URL format;
trailingSlashand links agree - Redirects for moved URLs
- One
h1per page and a logical heading outline - Alt text on meaningful images
- Core Web Vitals in “Good” in field data
- Site verified in Google Search Console, sitemap submitted
The Astro configuration docs show where site, trailingSlash and integrations live in a theme’s config.
FAQ
Is Astro good for SEO?
Yes. Astro renders complete HTML on the server or at build time, so crawlers see all content without executing JavaScript, and pages load fast by default. You still need to add meta tags, a sitemap and structured data yourself or through a theme.
How do I add meta tags in Astro?
Create a component that renders <title>, <meta name="description">, a canonical link and Open Graph tags from props, and include it in the <head> of your base layout.
How do I add a sitemap to Astro?
Run npx astro add sitemap, set site in astro.config.mjs, and build. The sitemap is written to /sitemap-index.xml.
How do I add JSON-LD structured data in Astro?
Render <script type="application/ld+json" set:html={JSON.stringify(schema)} /> in the page or layout, where schema is a plain object following schema.org.
Does Astro generate canonical URLs automatically?
No. Build them in your head component with new URL(Astro.url.pathname, Astro.site) and render a <link rel="canonical"> tag.