Astro SEO: sitemap, meta tags and structured data

By Michael Nefedov, noetio · Updated 21 October 2026

Astro SEO starts from static HTML, which Astro builds by default: you set a site URL, add the sitemap integration, and put the right tags in one layout.

Short answer

  • Set site in astro.config.mjs. Sitemap and canonical URLs depend on it.
  • Run npx astro add sitemap. Put title, canonical, Open Graph and JSON-LD in one layout.
  • Every sample was built on Astro 7.4.1 and @astrojs/sitemap 3.7.4, on 21 October 2026.

Astro SEO step 1: set your site URL and static output

Set site in your config and keep static output. Astro uses site to build your sitemap and canonical URLs, and says setting it is strongly recommended (docs). The default output is "static", which prerenders all pages. Keep it for content pages.

astro.config.mjs

import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://example.com",
  integrations: [sitemap()],
});

Install with npx astro add sitemap, or add the package by hand as shown in the integration docs. A build now creates sitemap-index.xml and sitemap-0.xml.

In our build, the URLs in the sitemap had a trailing slash (https://example.com/pricing/) and no lastmod.

Sitemap options that matter

Use filter to drop pages, and ignore priority and changefreq.

astro.config.mjs

sitemap({
  filter: (page) => !page.includes("/thank-you"),
  serialize(item) {
    // Google ignores priority and changefreq. Add lastmod only if you know the real date.
    return item;
  },
})
  • filter removes pages you do not want listed.
  • Google ignores priority and changefreq and uses lastmod only when it is consistently accurate (Google).
  • We built this config with both options and it worked.
  • The docs warn that SSR dynamic routes are not listed, so prerender what you want indexed.
Google's sitemap notes: Google ignores priority and changefreq values; Google uses lastmod if it is consistently and verifiably accurate; lastmod should reflect the last significant update to the page.
Source: Google Search Central, Build and submit a sitemap, page updated 8 July 2026, captured 21 October 2026. Open full size.
Astro docs: this integration cannot generate sitemap entries for dynamic routes in SSR mode.
Source: Astro Docs, @astrojs/sitemap, captured 21 October 2026. Open full size.

Then point crawlers at it. In public/robots.txt:

public/robots.txt

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml

A layout with meta tags

Make one layout and use it for every page. Astro.site comes from your config, so the canonical is always a full URL. If you forget site, this layout fails the build. We removed site and got TypeError: Invalid URL while rendering a page.

src/layouts/Base.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).href;
const ogImage = new URL(image, Astro.site).href;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonical} />
    <link rel="sitemap" href="/sitemap-index.xml" />
    <meta property="og:type" content="website" />
    <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" />
    <slot name="head" />
  </head>
  <body>
    <slot />
  </body>
</html>

Use it in a page and pass a unique title and description. Write a title and description that fit that page only:

src/pages/pricing.astro

---
import Base from "../layouts/Base.astro";
---
<Base title="Pricing for Example: Plans and Limits" description="Example pricing: what each plan costs and what it includes.">
  <h1>Pricing for Example</h1>
</Base>

Structured data (JSON-LD)

Add JSON-LD with set:html on an object you build yourself. Astro does not escape HTML in set:html, so only pass values you trust (docs). For a JSON-LD block that is fine, because you build the object yourself. Add this inside the layout's head, or in a page through the head slot:

Astro docs on set:html: the value is not automatically escaped by Astro; be sure that you trust the value, or that you have escaped it manually, or you open yourself up to cross-site scripting attacks.
Source: Astro Docs, Template directives reference, captured 21 October 2026. Open full size.

src/pages/blog/my-post.astro

---
import Base from "../../layouts/Base.astro";
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "Article",
  headline: "My post title",
  datePublished: "2026-10-20",
  author: { "@type": "Person", name: "Your Name" },
};
---
<Base title="My post title" description="One sentence summary.">
  <script slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
  <h1>My post title</h1>
</Base>

If any value comes from users, replace < with \u003cin the string first. Test the page in Google's Rich Results Test.

Blog pages from content collections

Keep posts as Markdown in a collection and build one static page per post. This is the pattern from the Astro docs (content collections). First define the collection.

The page code below needs this file, or getCollection("blog") has nothing to read. Without it the build does not fail. On a clean install we got only [WARN] The collection "blog" does not exist or is empty, the build exited with success, and the post pages were silently not built:

src/content.config.ts

import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
  }),
});

export const collections = { blog };

Posts go in src/content/blog/, each with title and description in its front matter. Then the page. The sitemap integration lists every post it builds:

src/pages/blog/[id].astro

---
import { getCollection, render } from "astro:content";
import Base from "../../layouts/Base.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog");
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<Base title={post.data.title} description={post.data.description}>
  <h1>{post.data.title}</h1>
  <Content />
</Base>

The schema above makes both fields required. We added a post with a title but no description, and astro build stopped with InvalidContentEntryDataErrorand “description: Required”. A missing description fails the build instead of reaching search.

With the post in place, the build wrote /blog/hi/index.html with the post's H1.

Redirects: a catch on static sites

On a static site, an Astro redirect is a meta refresh, not a 301.

astro.config.mjs

export default defineConfig({
  redirects: { "/old-page": "/new-page" },
});

The Astro docs say that a static site with no adapter produces a client redirect with a <meta http-equiv="refresh"> tag, which does not support status codes. Status codes work with SSR, or with a static adapter in static mode.

Astro docs: for statically-generated sites with no adapter installed, a redirect produces a client redirect using a meta http-equiv refresh tag and does not support status codes.
Source: Astro Docs, Configuration reference (redirects), captured 21 October 2026. Open full size.

For the config above, our build wrote dist/old-page/index.html with this head:

dist/old-page/index.html (build output)

<title>Redirecting to: /new-page</title>
<meta http-equiv="refresh" content="0;url=/new-page">
<meta name="robots" content="noindex">
<link rel="canonical" href="https://example.com/new-page">

So the old page carries a canonical pointing to the new page, and a noindex. In a test project with a real /new-page, the sitemap listed /new-page/ and not /old-page/.

Google's redirect page lists “meta refresh (0 seconds)” as a permanent redirect type, but says a server-side redirect has the highest chance of being read correctly (Google). If you need a real 301 or 308, set it in your host's redirect rules or add an adapter, and check key URLs in URL Inspection after a move.

Google's redirect page: the table orders redirect types by how likely Google is to interpret them correctly; a server-side redirect has the highest chance of being interpreted correctly.
Source: Google Search Central, Redirects and Google Search, page updated 14 April 2026, captured 21 October 2026. Open full size.

Check what crawlers get

Build, then curl your own site and read the first HTML.

terminal

npm run build
curl -s https://example.com/ | grep -E "<title>|canonical|<h1"
curl -s https://example.com/sitemap-index.xml

In static output the title, canonical and H1 are in the first HTML. That is the safe setup: Google says pre-rendering is a great idea because not all bots run JavaScript (Google).

Google's JavaScript SEO basics: server-side or pre-rendering is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript.
Source: Google Search Central, Understand the JavaScript SEO basics, page updated 4 March 2026, captured 21 October 2026. Open full size.

We also tested islands. In a page with one React component rendered with client:only="react" and one with client:load, the built HTML had the text of the client:load one only. So the client:only island had no rendered text in the HTML.

Its props, including a label string, were in the props attribute of the astro-island tag, which is not visible page text. This is our test, not a quote from the directives reference. Keep text you want ranked out of those.

Common Astro SEO mistakes

Six mistakes cause most Astro SEO problems.

  • No site in config. With the layout above, the build fails with TypeError: Invalid URL. Astro's docs also use site for the sitemap.
  • Trailing slash mismatch. In our static build both the canonical and the sitemap used /pricing/, so they matched. Check that yours do too. The trailingSlash option defaults to "ignore" and affects the dev server and on-demand pages, so check your host as well.
  • Switching to SSR and losing sitemap entries. The docs say dynamic SSR routes are not listed.
  • Main text inside client:only components. It is missing from the HTML.
  • One title for all pages. Pass title and description as props from each page.
  • Forgetting robots.txt after launch. Add the Sitemap line, then submit the sitemap in Search Console.

Astro SEO checklist

Tick these eight items before you launch.

  1. 1site set in astro.config.mjs
  2. 2output left as static for content pages
  3. 3sitemap integration installed, sitemap-index.xml opens
  4. 4One layout with title, description, canonical, Open Graph
  5. 5Unique title and description per page
  6. 6JSON-LD on articles and the home page
  7. 7robots.txt with the Sitemap line
  8. 8Sitemap submitted in Search Console

After the code: submit the site to Google Search Console. On Next.js instead? See Next.js SEO.

Sources

All read on 21 October 2026. Our build outputs above come from Astro 7.4.1 and @astrojs/sitemap 3.7.4, rebuilt on 21 October 2026; versions from npm view astro version and npm view @astrojs/sitemap version.

  1. @astrojs/sitemap, Astro Docs
  2. Configuration reference, Astro Docs
  3. Template directives reference, Astro Docs
  4. Content collections, Astro Docs
  5. Build and submit a sitemap, Google Search Central, last updated 8 July 2026
  6. Redirects and Google Search, Google Search Central, last updated 14 April 2026
  7. Understand the JavaScript SEO basics, Google Search Central, last updated 4 March 2026

Questions

Yes. Astro builds static HTML by default, so crawlers get your full content in the first response, with no JavaScript needed.

Not for dynamic routes. The Astro docs say the integration cannot generate sitemap entries for dynamic routes in SSR mode. Prerendered pages are fine. The docs describe customPages as a way to add pages that are on your deployed site but not created by Astro. We did not test it with SSR routes.

Not for the basics in this guide. The layout and sitemap samples use only the official sitemap integration and one layout, and the build output had the title, canonical, Open Graph tags, JSON-LD and a sitemap.

Astro 7.4.1 and @astrojs/sitemap 3.7.4, the latest on npm on 21 October 2026. Newer versions may behave differently, so run the curl step above on your own build.

More guides

Turn your next topic into a useful page

Compass reads your website, helps you plan relevant topics and creates sourced article drafts. Review and approve the work, then send it to a connected CMS or export it.

Written by Michael Nefedov, founder of noetio. Updated 21 October 2026.