Guide · 10 min read

Astro SEO: the practical checklist

Astro ships static HTML with zero JavaScript by default, so the hard part is done. This checklist covers what's left, with code for Astro 5 through 7.

Updated · By the Serpkin team

In short

  • Astro prerenders HTML and ships no JavaScript by default, so crawlers see your content. Keep static output for content pages.
  • Set site in astro.config, pick trailingSlash and a matching build.format, and build canonicals with new URL(Astro.url.pathname, Astro.site).
  • Add @astrojs/sitemap (it writes sitemap-index.xml) and point robots.txt at it. Don't block /_astro/.
  • Render JSON-LD with set:html and escape <, add src/pages/404.astro, and use an adapter or host config for real 301 redirects.
  • Astro writes no hreflang tags: add them in the layout for multilingual sites, and keep client:only and server islands away from copy you want indexed.

Astro is one of the easiest frameworks to get right for SEO. Pages are prerendered to static HTML by default, and components ship zero JavaScript unless you opt in with an island. The content crawlers need is already in the HTML. What Astro doesn't do for you: write your titles and canonicals, set site, pick a trailing-slash style, or keep your sitemap and robots.txt in sync. This checklist covers those gaps for Astro 7 (current as of October 2026; the APIs here also work in Astro 5 and 6).

What Astro gives you, and what it doesn't

  • Static by default. With the default output: "static", every page is built to HTML at build time. Crawlers that never run JavaScript (many AI crawlers among them) see the full page.
  • Zero JS by default. Astro components render to HTML only. React, Svelte or Vue components stay static unless you add a client:* directive, and each island hydrates on its own, which helps INP.

Two exceptions put content outside the HTML. A component with client:only skips server rendering and exists only after JavaScript runs. A server island (server:defer) ships its fallback in the HTML and fetches the real content after the page loads. Both are fine for a cart or an avatar, not for copy you want indexed.

The rest is on you, in the order below: one URL per page, metadata, discovery, then extras.

Static or on-demand rendering

Keep the default static output for content pages. To render some routes on request (a search page, a dashboard, anything personalized), add an adapter (npx astro add vercel, node, netlify or cloudflare) and opt those routes out of prerendering:

src/pages/search.astro
---
export const prerender = false; // this route renders on each request
const q = Astro.url.searchParams.get("q") ?? "";
---

If most of your site is dynamic, set output: "server" and mark static pages with export const prerender = true instead. On-demand pages are still server-rendered HTML, so they are fine for SEO. What changes is the sitemap and status codes (below).

One URL per page: site, trailingSlash and build.format

Set site to your production origin. Astro uses it for Astro.site, canonical URLs and the sitemap. Then pick one trailing-slash style and make build.format match it, so the URL in dev, in the build and on your host is the same.

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

export default defineConfig({
  site: "https://your-site.com",
  trailingSlash: "always",      // "always" | "never" | "ignore" (default)
  build: { format: "directory" }, // /about/index.html, served as /about/
  integrations: [sitemap()],
  redirects: {
    "/blog/old-slug": "/blog/new-slug/",
  },
});
Pick one pair and keep canonicals, sitemap and internal links in the same style
URL styletrailingSlashbuild.formatOutput
/about/"always""directory"about/index.html
/about"never""file"about.html

Preview deploys and staging

Because site is hardcoded, canonicals on a preview deploy point at production, which is what you want. The risk is a preview or staging host getting indexed as a duplicate. Hosts handle this differently: Vercel sends X-Robots-Tag: noindex on preview deployments, except when you attach a custom domain to a non-production branch, and Cloudflare Pages adds the same header to every preview deployment. Check yours rather than assume:

Terminal
curl -sI https://your-branch.your-host.app/ | grep -i x-robots-tag
curl -sI https://your-site.com/ | grep -i x-robots-tag   # should print nothing

For a staging domain the host doesn't protect, pass noindex to the layout from an environment variable, and make sure the production build can't inherit it. A stray noindex copied from staging keeps a launched site out of search entirely, and nothing in the build warns you.

Put every head tag in one layout and have each page pass its own title and description. Build the canonical from the request path and Astro.site, so it always points at production, even on a preview deploy.

src/layouts/BaseLayout.astro
---
interface Props {
  title: string;
  description: string;
  image?: string;     // path under public/, or an absolute URL
  noindex?: boolean;
}
const { title, description, image = "/og/default.png", noindex = false } = 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} | Acme`}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonical} />
    {noindex && <meta name="robots" content="noindex" />}
    <meta property="og:type" content="website" />
    <meta property="og:url" content={canonical} />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={ogImage} />
    <meta name="twitter:card" content="summary_large_image" />
    <link rel="sitemap" href="/sitemap-index.xml" />
    <slot name="head" />
  </head>
  <body>
    <slot />
  </body>
</html>
src/pages/pricing.astro
---
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout
  title="Pricing"
  description="Plans for teams of 1 to 50. Start free, upgrade when you add a second project."
>
  <h1>Pricing</h1>
</BaseLayout>

Astro.site is undefined when site isn't set, and the canonical then comes out relative or wrong. If you use query strings for filters, note that Astro.url.pathname drops them, which is usually what you want for a canonical. Our canonical tags guide covers the edge cases (syndication, duplicates).

og:image must be an absolute URL, which is why the layout wraps it in new URL(image, Astro.site). Use 1200×630 files in public/og/, or generate one per post at build time with an endpoint such as src/pages/og/[id].png.ts (libraries like satori render the title to an image). Community packages such as astro-seo wrap these same tags in a component. A hand-written layout is about 30 lines and has no dependency to keep updated; either works.

Content collections and per-page metadata

For a blog or docs, define a schema so every entry must have a title and description. A missing description then fails the build instead of shipping silently.

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(60),
    description: z.string().min(70).max(160),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };
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}>
  <article>
    <h1>{post.data.title}</h1>
    <Content />
  </article>
</BaseLayout>

Filtering drafts in getStaticPaths means they never get a URL or a sitemap entry.

Paginated archives

paginate() in getStaticPaths builds one URL per page of a list, and the page prop gives you page.currentPage and page.url.prev/next. Two things to get right: give each page its own title ("Blog, page 2"), so they don't all share one, and give each page a self-referencing canonical. Google says not to canonicalize page 2 and beyond to page 1. Link pages with plain <a href> prev and next links so crawlers can reach older posts.

Multilingual sites: i18n routing and hreflang

Astro's i18n config handles locale routing (/es/pricing/), and astro:i18n gives you URL helpers, but Astro doesn't write hreflang tags for you. Add them in the layout. Each language version should list every version, itself included, and Google recommends an x-default for visitors who match none:

astro.config.mjs
export default defineConfig({
  site: "https://your-site.com",
  i18n: { locales: ["en", "es"], defaultLocale: "en" }, // en has no /en/ prefix
  integrations: [
    sitemap({ i18n: { defaultLocale: "en", locales: { en: "en-US", es: "es-ES" } } }),
  ],
});
src/layouts/BaseLayout.astro
---
import { getAbsoluteLocaleUrl } from "astro:i18n";
const locales = ["en", "es"];
const current = Astro.currentLocale ?? "en";
// "/es/pricing/" -> "pricing/", "/pricing/" -> "pricing/"
const path = Astro.url.pathname.replace(new RegExp("^/" + current + "(/|$)"), "/").slice(1);
---
{locales.map((l) => <link rel="alternate" hreflang={l} href={getAbsoluteLocaleUrl(l, path)} />)}
<link rel="alternate" hreflang="x-default" href={getAbsoluteLocaleUrl("en", path)} />

This assumes every page exists in every language. If a page is missing in one locale, leave that locale out of its hreflang set. Watch fallbackType: "rewrite": it serves the fallback language's content at the localized URL, so /fr/pricing/ can be a copy of the Spanish page. Don't list such pages as translations. The sitemap's i18n option adds matching xhtml:link alternates to each entry.

Sitemap and robots.txt

@astrojs/sitemap

Run npx astro add sitemap. With site set, the build writes sitemap-index.xml and sitemap-0.xml (a new file every 45,000 URLs by default, set by entryLimit). It includes every static page and every getStaticPaths route. Exclude pages that shouldn't be indexed with filter:

astro.config.mjs
sitemap({
  filter: (page) => !page.includes("/thanks/") && !page.includes("/drafts/"),
})

The integration can't list dynamic routes that render on demand. If you have those, add their URLs with the customPages option or write your own sitemap endpoint.

It writes only <loc> unless you add more. Skip changefreq and priority (Google ignores both). lastmod is worth it only if it's accurate: Google uses it when it is consistently correct, so don't stamp every URL with the build date. Set it per URL in serialize, for example from a map of post slugs to their updatedDate.

robots.txt

The simplest option is a static public/robots.txt. If you want the sitemap URL to follow site, use an endpoint instead (it is prerendered to a file in static output):

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: *
Allow: /

Sitemap: ${sitemap}
`);
};

JSON-LD with set:html

Astro doesn't process a <script> that has any attribute other than src, so a JSON-LD script is output as is. Use set:html to put the JSON inside it. set:html is not escaped by Astro, so escape < yourself; otherwise a title containing </script> breaks out of the tag.

src/pages/blog/[id].astro
---
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "Article",
  headline: post.data.title,
  datePublished: post.data.pubDate.toISOString(),
  dateModified: (post.data.updatedDate ?? post.data.pubDate).toISOString(),
  mainEntityOfPage: new URL(Astro.url.pathname, Astro.site).href,
};
---
<BaseLayout title={post.data.title} description={post.data.description}>
  <script
    slot="head"
    type="application/ld+json"
    set:html={JSON.stringify(jsonLd).replace(/</g, "\\u003c")}
  />
  ...
</BaseLayout>

The slot="head" puts it in the layout's head slot (JSON-LD in the body also works). Only mark up what is visible on the page. Our structured data guide lists which types are worth adding and how to validate them.

404 page and redirects

Create src/pages/404.astro. It builds to 404.html, which static hosts (Vercel, Netlify, Cloudflare Pages) serve with a 404 status for unknown URLs. On on-demand pages, return a real 404 when a record doesn't exist, instead of a 200 that says "not found":

src/pages/products/[slug].astro
---
export const prerender = false;
const product = await getProduct(Astro.params.slug);
if (!product) Astro.response.status = 404; // a real 404, not a soft 404
---
{product ? <ProductView product={product} /> : <NotFound />}

For renamed URLs, use the redirects config shown earlier. GET redirects default to 301. You can set a status with the object form, for example "/news": { status: 302, destination: "/blog/" }.

Images with astro:assets

Use <Image /> from astro:assets. For local imports it infers width and height, which prevents layout shift (CLS). For images in public/ or remote images, pass width and height (or inferSize for authorized remote domains). alt is required: Astro errors without it. Use alt="" for decorative images.

src/pages/index.astro
---
import { Image } from "astro:assets";
import hero from "../assets/hero.png";
import divider from "../assets/divider.png";
---
<Image src={hero} alt="The Acme board view with three columns of tasks" priority />
<Image src={divider} alt="" /> {/* decorative */}

Images are lazy-loaded by default. For the main image above the fold (often your LCP element), add priority (Astro 5.10+), which sets loading="eager" and fetchpriority="high".

Link with plain <a href> elements, with descriptive anchor text, and keep hrefs in your trailing-slash style so you don't link to redirects.

Browser-native view transitions are just CSS and change nothing for crawlers. Astro's <ClientRouter /> (from astro:transitions) turns the site into a single-page app: links stay real <a href> elements and each page is still full HTML on the server, so crawlers see no difference. The risk is client-side: analytics and other inline scripts may not run on every navigation. Put setup code in an astro:page-load listener, or add data-astro-rerun to inline scripts.

Troubleshooting

Common Astro SEO problems: symptom, cause, fix
SymptomLikely causeFix
Canonical is relative, or og:image is missing a hostsite not set, so Astro.site is undefinedSet site in astro.config and rebuild
Search Console: “Duplicate without user-selected canonical” for /x and /x/trailingSlash: "ignore" and no host redirectPick "always" or "never" with a matching build.format
No sitemap-index.xml in dist/site missing, or the integration isn't in integrationsRun npx astro add sitemap and set site
Some pages missing from the sitemapThey render on demand (prerender = false)Add them with customPages or a custom endpoint
Text is on the page but not in curl outputIt's in a client:only component or a server islandMove indexable copy into an Astro component, or hydrate with client:load so it is server-rendered first
curl -I on an old URL shows 200, not 301Static build with no adapter writes a meta refresh pageAdd an adapter or host redirects (vercel.json, _redirects)
Unknown URLs return 200No 404.astro, or an on-demand route renders "not found" without setting the statusAdd src/pages/404.astro; set Astro.response.status = 404
Preview URLs appear in GooglePreview or staging host serves no noindexCheck curl -sI for x-robots-tag; add noindex on staging only

Check it before you ship

  • site is your production URL, and trailingSlash matches build.format.
  • Every page passes its own title and description to the layout; each has a self-referencing canonical.
  • curl -s on a page shows the H1 and main copy in the HTML (it will, unless content loads in an island).
  • /sitemap-index.xml lists only indexable URLs, and robots.txt points to it.
  • Unknown URLs return 404, and renamed URLs return a real 301 (not just a meta refresh).
  • JSON-LD escapes < and validates in the Rich Results Test.
  • Every <Image /> has dimensions and a meaningful alt; the hero has priority.
  • Production sends no noindex header or tag; multilingual pages carry a full hreflang set.

Most of this takes a minute from the terminal after astro build and a deploy:

Terminal
grep -o '<link rel="canonical"[^>]*>' dist/pricing/index.html
curl -sI https://your-site.com/about | head -3     # 301/308 to /about/, or 200 if that's your style
curl -sI https://your-site.com/blog/old-slug | head -3
curl -s -o /dev/null -w "%{http_code}\n" https://your-site.com/does-not-exist   # 404
curl -s https://your-site.com/sitemap-0.xml | grep -c "<loc>"

For a quick look at one URL (server HTML, title, canonical, robots, sitemap, structured data, Open Graph), run it through the free SEO check. To catch regressions before they reach production, compare a preview with your live site:

  1. Deploy a preview

    Push your branch and wait for the preview URL (for example a vercel.app, netlify.app or pages.dev URL).

  2. Compare it with production
    Terminal
    npx serpkin check https://your-branch.netlify.app

    Serpkin compares titles, descriptions, canonicals, noindex, status codes, robots.txt, the sitemap and JSON-LD on the preview against production. It exits with code 1 on a regression, so it works in CI.

  3. Fix, then merge

    For Astro, mapping a finding to its source file is best effort, so the report may name a page rather than its exact file.

Check a page nowFree: see what a crawler finds on any URL, with a prompt for your agent.

Questions

Is Astro good for SEO?

Yes. Astro prerenders pages to static HTML and ships no JavaScript unless you add an island, so content is in the HTML and pages tend to be fast. You still need to write titles, descriptions and canonicals, set site, and add a sitemap.

Does Astro generate a sitemap automatically?

Not without the @astrojs/sitemap integration. Install it with npx astro add sitemap and set site in astro.config. It writes sitemap-index.xml and sitemap-0.xml at build time, but it can't list dynamic routes that render on demand.

How do I add a canonical URL in Astro?

Set site in astro.config, then in your layout add <link rel="canonical" href={new URL(Astro.url.pathname, Astro.site)} />. Keep the trailing-slash style consistent with your trailingSlash setting.

How do I add hreflang tags in Astro?

Astro's i18n routing handles localized URLs but doesn't output hreflang. In your layout, add a <link rel="alternate" hreflang> for every language version (built with getAbsoluteLocaleUrl from astro:i18n) plus an x-default, and set the sitemap integration's i18n option.

Do Astro view transitions hurt SEO?

No. Each page is still full server HTML and links stay normal <a href> elements. The risks are client-side: scripts like analytics may not rerun on navigation unless you use astro:page-load or data-astro-rerun.

Sources

Let your agent do the SEO work.

Serpkin finds the next fix from real search data, hands it to Claude Code, Codex or Cursor, and checks it after you ship.

npx serpkin
Start free