Astro SEO

Astro is one of the strongest frameworks for SEO — pages prerender to static HTML by default and ship JS only where you ask for it, with strong Core Web Vitals. But defaults aren't guarantees, and it doesn't write your meta tags, sitemap, or canonicals for you. Here's how I run my own Astro site, and how to get the architecture right.

First published: Jun 26, 2026 · Last updated: Jul 17, 2026 · Advanced
demand #4 in JavaScript Frameworks#23 in Platform SEO#188 in Technical SEO#248 on the site

Astro is one of the strongest framework choices for SEO because it prerenders to static HTML by default — your content is in the raw HTML on first crawl, with no rendering queue to wait on for that route. Islands hydrate only the components you mark with a client directive, so most of the page ships without its own hydration JS (Astro can still add page scripts and router JS elsewhere) — none of this guarantees crawlability, indexing, or rankings on its own, so verify the deployed routes. Astro sites also post strong Core Web Vitals numbers. The catch: Astro generates clean HTML but doesn't write your meta tags, canonicals, sitemap, or structured data — those are deliberate build steps, and sitemap discovery targets statically generated routes, so runtime-only URLs need explicit handling. Server Islands (which require an adapter) and View Transitions are SEO-safe when you understand what crawlers actually fetch. I run patrickstox.com on Astro, so this is the stack I actually live in.

TL;DR — Astro is architecturally well set up for SEO: pages and endpoints are prerendered to static HTML by default, so there’s no render-queue wave to wait on for that content — but that’s a default, not a universal guarantee, and static/server HTML doesn’t by itself prove crawlability, indexing, rankings, or Core Web Vitals for your production URLs. Islands architecture hydrates only the components you mark with a client:* directive — everything else ships HTML without its own hydration JS, though page scripts, other islands, and router enhancements can still add JavaScript elsewhere on the page. Astro doesn’t auto-generate meta tags, canonicals, sitemaps, or structured data — wire those up explicitly, ideally validated by Content Collections + Zod. Server Islands (which require an adapter) serve the static shell with fallback content in the initial document and fetch the deferred content independently afterward — verify what a given crawler actually retrieves rather than assuming. View Transitions use history.pushState and are SEO-safe; Google crawls the underlying MPA pages normally. I run patrickstox.com on Astro, and the features below are the ones I actually use — validated against the deployed site, not just local dev.

Evidence for this claim Astro prerenders pages as static HTML by default and only sends client JavaScript for explicitly hydrated components. Scope: Astro default static output and islands architecture. Confidence: high · Verified: Astro: Why Astro

Why Astro sidesteps the JavaScript rendering problem entirely

The whole reason JavaScript SEO is hard is the second wave. Google fetches your raw HTML first, then queues the page for rendering in a headless Chromium later — and that queue is the risk. Google’s own docs describe it: “Googlebot queues all pages with a 200 HTTP status code for rendering unless a robots meta tag tells Google not to index the page. The page may stay on this queue for a few seconds, but it can take longer than that.” For a client-rendered SPA, your content doesn’t exist until that render wave runs.

Astro’s default output mode is static: pages and endpoints are prerendered to a complete HTML file at build time. So for a route running in that default, the raw HTML is the rendered page. Evidence for this claim Astro uses static output and prerenders routes at build time by default. Scope: Astro default output mode; routes can opt out of prerendering. Confidence: high · Verified: Astro: On-demand rendering There’s no second wave to wait on for that route, because there’s nothing left to execute. Googlebot sees the full content on the first fetch. As Joost de Valk (Yoast’s founder) puts it, “From an SEO perspective, static HTML on a CDN is a better starting point than most CMSes will ever give you.”

That’s the default, though — not a universal property of every route. Set output: 'server' and the default flips to on-demand rendering (more on that below); even in a static-default project, an adapter lets an individual route opt out with export const prerender = false. None of this is guaranteed by architecture alone: static or server HTML, islands, and adapters don’t by themselves guarantee crawlability, indexing, rankings, or Core Web Vitals — those depend on the deployed route and the specific crawler, so verify them rather than assuming the framework handles it.

This also helps with the crawlers that can’t render at all. Google notes plainly that “not all bots can run JavaScript” — and that’s the 2026 reality for most AI crawlers and many third-party tools. Astro’s HTML-first output is readable by every one of them where it’s actually prerendered, not just Googlebot. (This is the same point I make in SEO for a headless CMS: the rendering mode is the product.)

Islands architecture: JS only where you ask for it

Astro renders your components to HTML and, in its words, ships “just HTML & CSS, stripping out all client-side JavaScript automatically.” Interactivity is opt-in. You mark a component with a client:* directive — client:load, client:idle, or client:visible — and only that island hydrates with JavaScript. Everything else stays static HTML. Evidence for this claim Astro client directives selectively hydrate interactive islands while other components remain static HTML. Scope: Astro islands and client directives. Confidence: high · Verified: Astro: Islands

For SEO this is close to ideal. The content Googlebot needs to index is plain HTML, and your interactive widgets don’t drag it down. client:visible is especially useful: a below-the-fold component doesn’t even begin hydrating until it scrolls into view, so it never blocks your LCP. The concept comes from Jason Miller (creator of Preact), who described “rendering HTML pages on the server, and inject[ing] placeholders or slots around highly dynamic regions” for selective hydration.

Two nuances worth being precise about, because competitor coverage tends to blur them:

  • client:only is a different animal. Unlike client:load/client:idle/client:visible, a client:only component skips server rendering entirely — it produces no HTML on the server at all. Anything indexable placed only inside a client:only component isn’t in the document Googlebot fetches; it only exists after the browser hydrates it. Don’t put primary content there.
  • This is selective hydration, not resumability. Astro re-runs each island’s client code from scratch in the browser; it doesn’t resume execution state that was serialized on the server the way a resumability model (Qwik’s, for example) does. The two get conflated in writeups — they’re not the same mechanism.
Evidence for this claim A client:only component skips server rendering, so indexable content placed only inside it cannot be assumed to exist in the initial page HTML. Scope: client and server islands Confidence: high · Verified: Template directives reference

And a non-hydrated component isn’t the whole JavaScript picture on a page. “Zero JS” describes one component without a client:* directive — Astro can still ship page-level <script> tags, the View Transitions router, and other islands elsewhere on the same page. Describe what ships per component/route, not as a blanket claim about the page.

What Astro does NOT do automatically

Astro generates clean semantic HTML — and nothing else, SEO-wise. There is no metadata, no canonical, no sitemap, no structured data out of the box. The myth that “Astro is automatically SEO-optimized” is exactly that. You own:

  • Meta tags (title, description, Open Graph, Twitter)
  • Canonical URLs
  • The sitemap (via the official integration)
  • Structured data / JSON-LD
  • robots.txt

Astro is the best foundation I’ve used, but it’s a foundation, not a finished house.

The sitemap: @astrojs/sitemap

Install it with npx astro add sitemap. It crawls your statically-generated routes and emits a sitemap-index.xml plus chunked sitemap-0.xml files at build time. Two things bite people:

Evidence for this claim Astro's official sitemap integration generates sitemap files from statically generated routes. Scope: Astro @astrojs/sitemap integration. Confidence: high · Verified: Astro: Sitemap integration
  • You must set site: in astro.config.mjs. Without it, the integration silently generates nothing. This is the single most common “where’s my sitemap?” cause.
  • You must add the sitemap line to robots.txt yourself. Astro doesn’t do it.

For control, filter() excludes routes (preview/draft pages — exactly what I use it for on this site), serialize() lets you set lastmod/changefreq/priority, and the i18n option emits hreflang entries in the sitemap. This is what the current @astrojs/sitemap docs specify — confirm against your installed version if you’re on an older release, integration behavior has changed across major versions before.

The scope that trips people up: the integration’s discovery targets statically generated routes. If any of your URLs only exist at runtime — server-rendered (output: 'server') routes, or routes generated on demand rather than at build time — don’t assume they’re in the sitemap. Add them explicitly with customPages, then actually open sitemap-index.xml after a build to confirm they’re there. Don’t take “the integration handles it” on faith for anything that isn’t a build-time static route.

Meta tags and canonicals: the BaseLayout pattern

Astro has no special <Head> component — you control <head> directly in .astro files. The standard pattern (and what I do) is a single BaseLayout.astro that takes title, description, and canonicalURL as props and writes the head. Set the canonical explicitly on every page and keep it consistent with og:url. You don’t need a library, but the community astro-seo package (npm) is a convenient one-component wrapper for title/description/OG/Twitter/canonical if you want it.

Content Collections as an SEO safety net

This is Astro’s underrated SEO feature. Content Collections are a type-safe content layer for your Markdown/MDX/JSON, with Zod schema validation. That means you can make title and description required fields — and if a page is missing one, the build fails. You can’t accidentally ship a page with no title. Query functions (getCollection(), getEntry()) generate static pages at build time, so the output is plain HTML when deployed. And because MDX keeps raw markdown as the source of truth, those files are also clean source content for AI crawlers and llms.txt-style patterns. This very site is built on Content Collections with Zod-validated frontmatter.

astro:assets: images done right (with one trap)

The <Image /> component automatically converts to WebP, infers dimensions to “avoid Cumulative Layout Shift (CLS),” sets loading="lazy" by default, and requires alt — a missing alt is a compile error. <Picture /> extends this with AVIF/WebP/fallback <source> elements.

The trap: the automatic loading="lazy" is wrong for your LCP image (usually the hero). Lazy-loading your most important image delays it. For above-the-fold images, override with loading="eager" and fetchpriority="high". Remote images need explicit width and height.

Server Islands: what crawlers actually see

Server Islands (Astro 4.12+) are the feature most competitor guides get wrong. With server:defer, a component renders on the server independently of the main page. The static shell is served immediately; per Astro’s docs, “Your page will be rendered immediately with any specified fallback content as a placeholder. Then, the component’s own contents are fetched on the client and displayed when available.”

Two things to be exact about. First, Server Islands require an adapter — they’re an on-demand feature, not something a purely static build produces on its own. Second, the sequence is: the initial document ships with whatever you configured as fallback content, and the island’s real content is a separate, independent request fetched after the page loads, over its own endpoint. That’s a hard floor on what the first document contains — I wouldn’t generalize past that about what every specific crawler does next without testing your own deployed URLs directly.

The SEO consequence is concrete: the static HTML a crawler reads on that first fetch contains your fallback content, not the deferred island content. That’s perfect for what Server Islands are for — personalized, session-specific stuff (login state, cart count, recommendations) that shouldn’t be cached or indexed anyway. It is wrong for primary indexable content. Put anything that needs to rank in the main Astro template, and let Server Islands handle the dynamic bits around it.

Output modes: static vs server, and the per-route override

Astro’s default output mode is static — pages and endpoints prerender to HTML at build time. Set output: 'server' in astro.config.mjs and the default flips to on-demand rendering: pages render per request (with an adapter), useful for auth, real-time data, or personalization beyond what Server Islands cover. Either way, you can override the default per route: in a static-default project, export const prerender = false opts a route into on-demand rendering; in a server-default project, export const prerender = true opts a route back into build-time prerendering. So “my site is static” or “my site is SSR” is rarely true for every route — check the per-route setting, not just the top-level config.

Evidence for this claim Astro uses static output and prerenders routes at build time by default. Scope: Astro default output mode; routes can opt out of prerendering. Confidence: high · Verified: Astro: On-demand rendering

From a pure SEO standpoint, prerendered and on-demand HTML are equivalent — both deliver complete HTML to a crawler’s first request, once you’ve actually confirmed the route returns a 200 with full markup. On-demand routes can stream their HTML, and slow data or network conditions can delay later chunks, so a streamed response isn’t automatic proof that every piece of content arrived — check it, don’t assume it. The real difference between the two modes is operational: prerendered content is fixed until the next build (or a separately configured runtime refresh) and serves straight from the CDN edge; on-demand content is always current but depends on your adapter and runtime being healthy in production. Don’t treat this as an SEO decision — choose on data freshness and ops, then validate the deployed routes, statuses, redirects, and response headers rather than extrapolating from what worked in local dev.

View Transitions: SEO-safe, despite the SPA feel

Astro’s <ClientRouter /> (formerly <ViewTransitions />) gives SPA-like soft navigation using the browser’s View Transitions API and the History API. The key fact: it navigates with history.pushState, which is exactly what Google recommends for client-side navigation — Google warns that fragment-based (#hash) URLs are something it “can’t reliably resolve.” Crucially, View Transitions are a browser-side enhancement. When Googlebot crawls, it requests each URL and gets a normal, complete HTML page — the MPA underneath is untouched. The transitions only affect what a human sees in the browser.

So no, View Transitions do not turn your Astro site into an SPA, and they don’t break SEO. A worth-noting gap: Astro’s View Transitions docs have no SEO section, which is probably why the “it breaks SEO” myth persists. To verify on your own site, fetch a few URLs directly and confirm each returns full HTML — don’t take it on faith, check your own deployment.

Common Astro SEO mistakes

  1. Assuming Astro handles SEO for you. It handles HTML. Meta, canonicals, sitemap, schema are yours.
  2. Forgetting site: in the config — your sitemap silently doesn’t generate.
  3. Not adding the sitemap to robots.txt — Astro won’t do it.
  4. Lazy-loading the LCP image — override the hero with eager + fetchpriority.
  5. Putting indexable content in a Server Island — crawlers see the fallback, not the content, and Server Islands require an adapter in the first place.
  6. Chasing a perfect Lighthouse score and stopping there. Speed is a ranking signal, not the ranking signal. A fast empty page doesn’t rank — content, links, and E-E-A-T still do the heavy lifting.
  7. Putting indexable content only inside a client:only component. Unlike the other client:* directives, client:only skips server rendering entirely — there’s no HTML for that component until the browser hydrates it.
  8. Treating “Astro is static/fast” as an outcome guarantee. Static or on-demand HTML, islands, and adapters are mechanisms — they don’t by themselves guarantee crawlability, indexing, rankings, or Core Web Vitals. Validate the deployed route, not the architecture diagram.

Where this fits in the cluster

Astro is one specific, unusually SEO-friendly answer to the questions JavaScript SEO raises about rendering, and a popular frontend for headless CMS setups. The performance side connects directly to Core Web Vitals in the web-performance cluster, and the “is my content actually in the HTML?” testing discipline is the same one from the crawling and indexing clusters.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin an expert quote first.