Technical SEO

Astro SEO: An Implementation-First Guide to Zero-JS, Sitemaps, and Schema

· · 17 min read

What is Astro SEO?

Astro SEO is technical SEO practised against Astro’s specific defaults, APIs, and gotchas — using its zero-JavaScript-by-default rendering to ship crawlable HTML, @astrojs/sitemap and a shared layout to handle canonicals and metadata, content collections to drive server-rendered schema, and client:* directive discipline to keep Core Web Vitals clean. The framework hands you the crawlable-and-fast stages almost for free; the work is not undoing that, and then making the output citable.

This is the Astro instalment of the SEO for engineers series. The pillar walks a single arc — crawlable → fast → understandable → citable — as four stages that each gate the next. Most of that guide is framework-agnostic. This one is not: it’s what the arc looks like when the stack is Astro, written from the seat of someone shipping the .astro files.

The headline is that Astro starts you further along the arc than almost any other choice. Where a plain React SPA has to escape client-side rendering to become crawlable at all, Astro’s default output is static HTML with no JavaScript attached. That posture is a real advantage — and it’s also easy to quietly throw away. This guide is about keeping it.

TL;DR — Key takeaways

  • Zero-JS-by-default is the strongest starting posture of any mainstream framework. Astro renders components to static HTML at build time and ships no client JavaScript unless you explicitly opt a component in. Your H1, body copy, and schema are in the raw HTML response by default — which is exactly what Googlebot’s first pass and every AI crawler need.

  • client:only is the one directive that breaks crawlability. It renders a component only on the client and emits nothing on the server. Everything else (client:load, client:idle, client:visible) still server-renders the HTML and merely attaches interactivity — so the content is always in the response.

  • @astrojs/sitemap needs site set to work. The integration generates sitemap-index.xml at build, but silently does nothing if you haven’t set site in astro.config.mjs. That single missing line is the most common Astro sitemap failure.

  • JSON-LD belongs in component frontmatter, emitted with set:html. Build the schema object in the frontmatter and render it with <script type="application/ld+json" set:html={JSON.stringify(schema)} />. Because Astro renders this at build time, it lands in the static HTML — visible to AI crawlers that never execute JavaScript.

  • Use astro:assets for every content image. The <Image /> component emits explicit width/height and lazy-loads by default, which kills the Cumulative Layout Shift that unsized <img> tags cause.

  • The technical fundamentals compound into AI citations. The same clean, static, schema-carrying HTML that ranks in Google is what an LLM lifts a sentence from. Ahrefs found 38% of Google AI Overview citations came from pages already ranking in the top 10, and ConvertMate’s 80M+ citation study measured a 67% lift in citation eligibility for content with valid schema.

Why Astro’s zero-JS default is the crawlable stage, solved

Stage one of the arc is crawlable: the content has to exist in the HTML a bot receives before any JavaScript runs. I’m not going to re-litigate the general SSR/SSG/ISR/CSR trade-offs here — that’s covered end to end in technical SEO for headless architecture, including the per-template rendering decision. What matters for Astro is that its default sits at the good end of that spectrum without you choosing anything.

Astro’s islands architecture renders every component to HTML at build time and ships zero client-side JavaScript by default. Interactivity is opt-in, island by island, rather than opt-out. The practical consequence for SEO is that the failure mode which dominates the React and Next.js spokes — primary content assembled client-side, invisible to first-pass and AI crawlers — is simply not Astro’s default. You have to go out of your way to create it.

The ten-second check from the pillar still applies, and on a stock Astro build it passes:

# What an AI crawler sees — it never runs your JS.
# On a default Astro build, your H1 and body are right here.
curl -sA "GPTBot" https://nadiamohamed.me/insights/astro-seo/ | grep -i "<h1\|zero-JavaScript"

If your H1 and body copy come back, you’re crawlable by everyone — Googlebot’s first pass and GPTBot, ClaudeBot, and PerplexityBot alike. The only way to fail this check in Astro is to hide content behind a client-only island, which is the next section.

client:* directive discipline and its INP cost

The one Astro directive that can undo the crawlable default is client:only. Here is the distinction that matters, because it’s the difference between content that’s in the HTML and content that isn’t:

---
import Chart from '../components/Chart.jsx';
import Comments from '../components/Comments.jsx';
---
<!-- Server-renders the HTML, THEN hydrates. Content is in the response. -->
<Chart client:visible />

<!-- Renders ONLY in the browser. The server emits nothing —
     invisible to every crawler that doesn't run JS. -->
<Comments client:only="react" />

Every directive except client:only still produces server-rendered HTML; they only change when the JavaScript attaches. So client:load, client:idle, and client:visible are all safe for crawlability. Reserve client:only for genuinely non-content UI that has no meaningful server render — and never wrap primary copy in it.

The second reason to be disciplined here is Interaction to Next Paint. As the pillar’s Core Web Vitals stage covers, INP regressions come from JavaScript blocking the main thread, and the biggest single lever is shipping less of it. Astro’s directives are effectively an INP budget expressed as an API. The hierarchy I reach for:

  • client:visible for anything below the fold — the island doesn’t hydrate until it scrolls into view, so it never competes with the initial interaction.
  • client:idle for low-priority interactive elements that can wait for the main thread to quiet down.
  • client:load only for above-the-fold interactivity the user might touch immediately.

The default that beats all three is no directive at all — a static component ships no JavaScript and has no hydration cost. Every island you add is a debit against INP, so the question for each one is whether it needs to be an island at all. Most “interactive” UI on a content site (accordions, tabs) can be built with HTML and CSS and stay at zero JS.

Image optimisation with astro:assets, and the CLS it prevents

The most common Cumulative Layout Shift failure is an image that loads without the browser knowing how much space to reserve, so everything below it jumps. Astro’s built-in astro:assets solves this at the component level: the <Image /> component requires dimensions and emits them into the markup.

---
import { Image } from 'astro:assets';
import diagram from '../assets/islands-diagram.png';
---
<!-- Local imports: Astro infers width/height from the file and
     reserves the box, so nothing reflows when the image arrives. -->
<Image src={diagram} alt="Astro islands rendered to static HTML" />

For a locally imported image, Astro reads the intrinsic dimensions and writes width/height into the <img> for you, and it lazy-loads and async-decodes by default. For remote images you must pass width and height explicitly (and authorise the host in your Astro config), because Astro can’t inspect a file it doesn’t build.

The one place to override the defaults is the LCP element. An above-the-fold hero should not be lazy-loaded — that delays the exact paint Largest Contentful Paint measures:

---
import { Image } from 'astro:assets';
import hero from '../assets/hero.png';
---
<!-- Above-the-fold: load eagerly and hint high priority
     so the LCP element isn't discovered late. -->
<Image src={hero} alt="…" loading="eager" fetchpriority="high" />

<Image /> passes extra attributes straight through to the underlying <img>, so loading and fetchpriority work as you’d expect. The rule of thumb: everything gets astro:assets for the free CLS protection; the one hero image gets loading="eager" to protect LCP.

The astro sitemap setup that actually generates a file

Secondary but load-bearing: your XML sitemap. The official @astrojs/sitemap integration builds one automatically — but it has a prerequisite that trips up nearly everyone the first time.

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

export default defineConfig({
  // Without `site`, @astrojs/sitemap generates NOTHING and stays silent.
  site: 'https://nadiamohamed.me',
  integrations: [sitemap()],
});

The site value is not optional for this to work. The integration derives absolute URLs from it, and if it’s missing, the build produces no sitemap and emits only a mild warning — so the failure is easy to miss until Search Console reports no submitted sitemap. With site set, the build emits sitemap-index.xml (and one or more sitemap-0.xml shards), which you then point search engines at from your <head>:

<link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />

Reference robots.txt at the same time so crawlers discover it without a manual Search Console submission. One Astro-specific hygiene note: the generated URLs inherit your trailingSlash and build.format config, so make sure those match what your canonical tags emit — a mismatch produces a sitemap that disagrees with your own canonicals, which is the kind of quiet contradiction the technical SEO audit methodology exists to catch.

Canonical tags and metadata from a single layout

In Astro, the natural home for canonical URLs and meta tags is a shared layout component that every page wraps in. Building the canonical from Astro.site and the current path keeps it correct everywhere without per-page duplication:

---
// BaseLayout.astro
interface Props { title: string; description: string; }
const { title, description } = Astro.props;
// Astro.site is your `site` config; this resolves to an absolute URL.
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<head>
  <title>{title}</title>
  <meta name="description" content={description} />
  <link rel="canonical" href={canonical} />
  <meta property="og:title" content={title} />
  <meta property="og:url" content={canonical} />
</head>

The gotcha here is the same trailingSlash interaction as the sitemap: Astro.url.pathname reflects your configured trailing-slash behaviour, so the canonical this produces must match the URL you actually serve and the one you list in the sitemap. Pick one policy in astro.config.mjs and let the layout be the single source of truth. Because this is a plain layout with no client:* directive, all of it renders server-side into the static HTML — no metadata is injected after hydration, which is exactly the property the headless architecture guide flags as the failure mode on JavaScript-heavy stacks.

Content collections and server-rendered schema

Stage three of the arc is understandable: structured data telling engines — and LLMs — what the page is. Astro’s content collections are a natural schema source because the frontmatter is already a typed, validated data object. This very site defines its blog collection with a Zod schema in src/content.config.ts, and that same typed frontmatter is what feeds the JSON-LD.

The critical implementation detail, and the one that decides whether AI crawlers can read your schema: the JSON-LD must be built in component frontmatter and emitted at build time, not injected client-side. Astro makes the right way the easy way. Build the object in the frontmatter, then render it with the set:html directive so Astro outputs the JSON without escaping it:

---
// BlogPost.astro — schema is assembled server-side and emitted as static HTML.
const { post } = Astro.props;
const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: post.data.title,
  datePublished: post.data.date.toISOString(),
  dateModified: (post.data.updated ?? post.data.date).toISOString(),
  // Reference the author as an entity by @id, defined once elsewhere —
  // don't duplicate the Person fields inline.
  author: { "@id": "https://nadiamohamed.me/about/#person" },
  mainEntityOfPage: {
    "@type": "WebPage",
    "@id": new URL(Astro.url.pathname, Astro.site).href,
  },
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />

Two things make this reliable. First, because there’s no client:* directive anywhere near it, the <script> is in the build output — it shows up in the curl -A "GPTBot" response from the crawlable section. Second, the @id reference points at a single Person node you define once (on your About page) with a populated sameAs array, rather than re-declaring the author on every post. The full priority stack of schema types and the Person/sameAs mechanics live in the structured data for AI search guide; the Astro-specific point is narrow — frontmatter plus set:html is the emission pattern, and it’s inherently server-side, so you get AI-crawler visibility by construction.

Then validate. Astro’s dev server won’t tell you your schema is malformed; run every template through Google’s Rich Results Test and the Schema.org validator before launch, and re-validate after any layout refactor — a silent schema regression from a component change is one of the most common findings on a site that’s been live a year.

View transitions: no crawl penalty, but two real caveats

Astro’s <ClientRouter /> (the component formerly named ViewTransitions) enables animated, SPA-style navigation between pages. It’s worth being precise about its SEO impact, because the intuition “SPA-style navigation is bad for SEO” is wrong here.

It does not hurt crawlability. Every route is still a fully server-rendered HTML document — crawlers and AI bots request each URL fresh and never touch the client router at all, so what they see is identical to a site without view transitions. The two caveats are real but narrower:

  1. It reintroduces client-side JavaScript. The router is a small script, but it does nudge you off the pure zero-JS posture that is Astro’s whole SEO advantage. On a mostly-static content site, weigh whether the animation is worth the budget.
  2. Inline scripts don’t re-run on client-side navigation by default. Because the router swaps the DOM instead of doing a full page load, analytics that fire on initial load, or setup scripts in a page’s <script>, won’t re-execute unless you make them transition-aware (listen for the astro:page-load event). The most common casualty is under-counted analytics pageviews — a measurement bug, not a ranking one, but worth catching.

Neither is a reason to avoid view transitions; both are reasons to test analytics and script lifecycle after enabling them.

When Astro’s on-demand rendering actually matters

Astro is static-first, and for a content site that’s the correct default — static HTML is the fastest, least ambiguous, most crawlable output there is. But some routes genuinely need to render per request: a page reading live data, or anything personalised. Astro handles this without forcing the whole site off static. You add an adapter and opt individual routes into on-demand rendering with a per-page export:

---
// A route that must render fresh on every request.
export const prerender = false;
const data = await fetch('https://api.example.com/live').then((r) => r.json());
---

The SEO guidance is simple: keep content pages prerendered so their HTML and schema are static and instantly crawlable, and reserve on-demand rendering for the handful of routes that truly can’t be built ahead of time. The decision is per-route, exactly as the headless rendering audit frames it — the difference is that Astro’s default already puts you on the safe side, so on-demand rendering is an escape hatch you reach for deliberately, not a default you have to escape.

Astro SEO best practices, in priority order

Pulling the arc together, here’s the order I’d ship Astro SEO best practices in — earlier items gate the value of later ones:

  1. Set site in astro.config.mjs. It’s the prerequisite for @astrojs/sitemap, absolute canonicals, and schema @ids. Everything downstream assumes it.
  2. Keep content out of client:only. Audit every client:* directive; anything wrapping primary copy should server-render. Confirm with the curl -A "GPTBot" check.
  3. Centralise canonical and meta in one layout built from Astro.site and Astro.url, with a trailingSlash policy that matches your sitemap.
  4. Emit JSON-LD from frontmatter with set:html, referencing a single Person entity by @id, and validate it.
  5. Route every image through astro:assets, with loading="eager" on the one LCP hero.
  6. Budget your islands. Prefer no directive, then client:visible, then client:idle, and treat client:load as the exception — INP is the metric you’re protecting.

From crawlable to citable: the Astro payoff

Here’s where the arc pays off, and it’s why Astro’s starting posture is worth protecting. Being crawlable, fast, and understandable makes a page eligible to be cited by ChatGPT, Perplexity, and Google AI Overviews. Astro gets you most of the way there by default — static HTML, server-rendered schema, clean INP — which means the last stage, citable, is less about undoing framework damage and more about how you write.

The structural moves that turn eligibility into citations are the same three from the pillar: lead each section with a self-contained, extractable answer; keep the author entity unambiguous through consistent Person schema; and serve every one of those signals in raw HTML. That last point is the through-line — an extractable answer or a JSON-LD block injected via JavaScript is invisible to AI crawlers, and the entire reason Astro is a strong choice here is that its default output puts all of it in the response.

38%
of Google AI Overview citations came from pages already ranking in the top 10
Source: Ahrefs — down from ~76% in July 2025 as Google shifted toward query fan-out
67%
higher AI citation eligibility for content with valid schema markup
Source: ConvertMate — analysis of 80M+ AI citations
Astro's zero-JS default puts clean HTML and server-rendered schema in the response by construction — the shared inputs to both Google rankings and AI citation eligibility.

The tactic-level version of the citable stage — how to shape the prose an LLM will quote — belongs to the GEO pillar. What’s Astro-specific is the head start: a developer who ships the six best practices above has already cleared the crawlable, fast, and understandable stages, which is most of the distance to citable.

Where this fits

This is one spoke of the SEO for engineers hub, alongside the React and Next.js instalments; the pillar is the map and this is the Astro territory. If you’re maintaining an Astro site over time, the technical SEO audit methodology is the standing-cadence version of the crawl, schema, and Core Web Vitals checks above, and structured data for AI search goes deep on the schema types the set:html pattern should emit. If your Astro site is one frontend among several pulling from a shared CMS, technical SEO for headless architecture covers the per-template rendering and schema-drift problems that come with it.

If you’d rather have this built and shipped than assembled internally, that’s what I do: full-stack development covers the build side — render strategy, Core Web Vitals, and schema deployed in the Astro codebase itself. Either way the arc is the same one this guide walks: Astro hands you crawlable and fast; you keep them, add understandable, and earn citable.

FAQ

Is Astro good for SEO?

Yes — Astro is one of the strongest frameworks for SEO because it renders components to static HTML at build time and ships zero client-side JavaScript by default. Your H1, body copy, and structured data are in the raw HTML response without any special configuration, which is exactly what Googlebot’s first-pass crawler and AI crawlers like GPTBot and PerplexityBot need. The failure mode common to plain React SPAs — primary content assembled client-side and invisible to crawlers — is not Astro’s default, so the SEO work is mostly a matter of not undoing that posture.

How do I add a sitemap to an Astro site?

Install the official @astrojs/sitemap integration and add it to your astro.config.mjs, but you must also set the site option to your production URL in the same config — without it, the integration silently generates nothing. Once site is set, the build emits sitemap-index.xml automatically. Reference it from your <head> with <link rel="sitemap" href="/sitemap-index.xml" /> and from your robots.txt so crawlers discover it.

Where should JSON-LD schema go in an Astro component?

Build the schema object in the component frontmatter (the code fence at the top of the .astro file) and render it with <script type="application/ld+json" set:html={JSON.stringify(schema)} />. Because Astro renders this at build time, the JSON-LD ends up in the static HTML rather than being injected after hydration — which means AI crawlers that never execute JavaScript can read it. The set:html directive stops Astro from escaping the JSON. Reference the author as an entity by @id rather than duplicating the Person fields on every page.

Does the client:only directive hurt SEO in Astro?

Yes, client:only can hurt SEO because it renders a component only in the browser and emits no server-side HTML — so any primary content inside it is invisible to search engines and AI crawlers. Every other directive (client:load, client:idle, client:visible) still server-renders the HTML and only changes when the JavaScript attaches, so those are safe for crawlability. Reserve client:only for genuinely non-content interactive UI, and confirm your content is in the response with curl -A "GPTBot" <url>.

Do Astro view transitions hurt SEO?

No — Astro’s <ClientRouter /> view transitions do not hurt crawlability, because every route is still a fully server-rendered HTML document that crawlers and AI bots request fresh, never touching the client-side router. The two real caveats are that it reintroduces a small amount of client-side JavaScript (nudging you off Astro’s zero-JS advantage) and that inline scripts don’t re-run on client-side navigation unless you make them listen for the astro:page-load event — which most often shows up as under-counted analytics pageviews, a measurement bug rather than a ranking one.

How do I prevent layout shift from images in Astro?

Use the <Image /> component from astro:assets for every content image. For locally imported images it infers width and height from the file and writes them into the markup, so the browser reserves the correct space and nothing reflows when the image loads — which is what prevents Cumulative Layout Shift. It also lazy-loads and async-decodes by default. The one exception is your above-the-fold LCP image, which should use loading="eager" (and optionally fetchpriority="high") so it isn’t delayed.