komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
SEO••By Komal Nakrani

Astro SEO: The Complete Checklist

Astro SEO checklist with code: set site, build a reusable head component, add a sitemap, robots.txt, canonical URLs, JSON-LD, RSS, and make pages citable by AI.

#Astro#SEO#Sitemap#JSON-LD#Meta Tags

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 site first. The sitemap, canonical URLs, RSS and Open Graph URLs all depend on it.
  • Put every head tag in one SEO.astro component and pass page-specific values as props.
  • npx astro add sitemap creates sitemap-index.xml on 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.

These are not Astro-specific, but templates make them easy to get wrong across a whole site:

  • One h1 per page. If your layout renders the title as h1, don’t start Markdown content with another # heading.
  • Logical heading levels. h2 for sections, h3 inside 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 pubDate and updatedDate, and keep the latter accurate.
  • An llms.txt file. A plain-text index at /llms.txt listing 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`);
}
SEO basics already done

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

  • site set 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/sitemap installed, private pages filtered out
  • robots.txt with a Sitemap: line
  • JSON-LD for the organisation, articles and products
  • RSS feed linked from <head>
  • One URL format; trailingSlash and links agree
  • Redirects for moved URLs
  • One h1 per 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.