All posts
Stack / how-to·Updated Aug 8, 2026·10 min read

SEO for Astro sites: a lightweight rank plan for content-first SaaS (as of July 2026)

Carlos, founder of CiteClip

TL;DR

How to do SEO for an Astro site: SSG-by-default, Content Collections, and @astrojs/sitemap give you a ranking foundation Next.js has to work harder to match. Here's the 9-item plan for any content-first Astro SaaS as of July 2026.

Astro is the fastest way to ship an SEO-optimized content-first SaaS site as of July 2026. SSG-by-default, zero client JavaScript by default, Content Collections for typed frontmatter, and a first-party sitemap integration mean 80% of the SEO work is done by Astro's defaults. You don't opt out of streaming. You don't opt out of hydration. There's nothing to hydrate. Crawlers get complete HTML on the first byte.

That's the good news. The bad news: Astro's SSG-first model constrains what you can build inside the same site. If your marketing/blog and your app dashboard live in one Astro repo, you'll fight the framework. If they're split (Astro for content, something else for the app) this is the SEO plan we'd run on the Astro half.

Companion post: our Next.js App Router SEO checklist covers the same layer for App Router. For what to publish inside these pages, see the solo-SaaS SEO five-page framework.

1. Astro's SSG-by-default advantage

Every page ships as complete HTML on first byte. Crawlers and AI engines that skip JavaScript still see the full content.

Astro renders every page to static HTML at build time. No React runtime ships to the client unless you explicitly hydrate a component with client:load or a similar directive. The first-byte HTML is complete: every H1, paragraph, link, and meta tag is there before any JavaScript runs.

yourdomain.com/postSTATIC HTML · 0 KB JS<Search />4 KB · client:load<CommentForm /><Reactions />hydrated islandstatic HTML
Astro renders the full page as static HTML. Interactive components are hydrated in-place as isolated “islands” that ship the minimum JS needed, everything else remains zero-JS.

For SEO this matters at three levels:

  1. 1Googlebot's rendering pipeline is faster because there's nothing to render. The HTML is complete on first fetch.
  2. 2AI engines that skip JavaScript execution (Perplexity, ChatGPT with browsing, Claude) can still fully parse your content.
  3. 3LCP is trivially fast because the DOM is complete from the first paint.

Combine this with edge caching on Vercel, Netlify, or Cloudflare Pages and every page serves in under 50ms globally. That's the Core Web Vitals ceiling for a content site.

2. @astrojs/sitemap + robots.txt

Install one integration, set your site URL, rebuild. You get a valid sitemap with zero code. robots.txt stays manual.

Install @astrojs/sitemap and add it to astro.config.mjs's integrations array. Set site in the config (required for sitemap URL generation). Rebuild. You get /sitemap-index.xml and /sitemap-0.xml automatically, listing every static route.

ts
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
integrations: [
sitemap({
changefreq: 'weekly',
priority: 0.7,
filter: (page) => !page.includes('/draft/'),
}),
],
});
astro.config.mjs. The `site` field is required, without it the sitemap generator has no origin to prefix URLs with.

For per-page control (priority, changefreq, exclusions), pass options to the integration. Drafts, admin, and preview routes are typically filtered out.

For robots.txt, create public/robots.txt manually. Astro doesn't ship a first-party robots integration as of July 2026. A minimal file:

bash
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /draft/
Sitemap: https://yourdomain.com/sitemap-index.xml
public/robots.txt. Two lines above your sitemap URL are enough for 95% of Astro sites.

3. Content Collections + MDX for the blog

Typed frontmatter means missing meta tags become build errors, not silent SEO holes.

Astro Content Collections (introduced in Astro 2, mature in 3+) give you typed frontmatter for a blog. Create src/content/config.ts and define a Zod schema for each collection. Every MDX file in src/content/blog/ must satisfy the schema at build time. Miss a field, build fails. Type-safe by construction.

ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().min(50).max(160),
publishedAt: z.date(),
updatedAt: z.date().optional(),
tags: z.array(z.string()).min(1),
author: z.string(),
image: z.string().url().optional(),
canonical: z.string().url().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
src/content/config.ts. Every field the layout needs is required upfront, no runtime surprises when a post ships with an empty description.

The blog post file itself is MDX: frontmatter block up top, JSX components mixed with markdown in the body. Import BaseLayout, ArticleHeader, CTABlock, whatever your components are. Content Collections make the frontmatter typed inside every layout and page that consumes them.

For routing, create src/pages/blog/[slug].astro. Use getCollection('blog') to load all posts, filter to published only, generate a static page per slug. Astro pre-renders every URL at build time.

As of July 2026 tourkit.us uses this exact Astro pattern for its content side. TTFB is under 40ms globally, LCP under 1 second.

4. BaseLayout.astro with slots

One 60-100 line file owns ~70% of your site's SEO surface. Change it in one place, applies everywhere.

Astro layouts use the <slot /> element for content injection. Create src/layouts/BaseLayout.astro with all the head tags (meta, OG, JSON-LD), the site header, the footer, and a single <slot /> in the middle. Every page imports BaseLayout and wraps its content in it.

astro
---
interface Props {
title: string;
description: string;
canonical?: string;
robots?: string;
ogImage?: string;
}
const {
title,
description,
canonical = new URL(Astro.url.pathname, Astro.site).toString(),
robots = 'index,follow',
ogImage = '/og-default.png',
} = Astro.props;
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta name="robots" content={robots} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
</head>
<body>
<slot />
</body>
</html>
src/layouts/BaseLayout.astro (abridged). The frontmatter script section destructures props; the template renders them into head tags. Every page overrides these by passing props.

For nested layouts (a BlogPostLayout that wraps BaseLayout and adds article-specific structure), Astro supports named slots: <slot name='header' /> inside a layout, populated via slot='header' on child elements from consumers. Use this to inject per-post breadcrumbs, table of contents, or related-posts widgets without duplicating markup.

5. Canonical URLs + meta robots per page

Compute canonical from Astro.site + Astro.url.pathname in the layout. Pick a trailing-slash policy and enforce it.

Astro exposes Astro.site (the site URL from config) and Astro.url (the current URL). Build canonical URLs by combining them:

ts
const canonical = new URL(Astro.url.pathname, Astro.site).toString();

Pass the result to BaseLayout as a prop. For pages that shouldn't be indexed (thank-you pages, internal previews, thin utility pages), pass robots='noindex,follow'. The layout renders the meta tag correctly.

Cross-domain canonicals for syndication: if you publish the same post on Medium or Dev.to, pass the canonical prop as the Medium URL from the original Astro page. Both platforms honor the canonical link.

As of July 2026 the Astro-recommended default is trailingSlash: 'ignore' for maximum compatibility across static hosts.

6. JSON-LD in the layout

Site-wide schema goes in BaseLayout. Per-page schema (Article, FAQPage) goes in the page. Validate every one before shipping.

Same pattern as Next.js. Inside BaseLayout.astro's <head>, render the JSON-LD as a script tag with set:html:

astro
<script
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
/>

Astro's set:html directive is the equivalent of React's dangerouslySetInnerHTML. Inlines the string without escaping.

Site-wide schema (Organization, WebSite, SoftwareApplication) goes in BaseLayout, loads on every page. Per-page schema (Article, FAQPage, HowTo, Product) goes in the specific layout (BlogPostLayout, ProductLayout) or the page itself.

FAQPage schema goes on posts with an FAQ block. Wrap the FAQ questions/answers in an <FAQ> MDX component that both renders the visible HTML and emits the JSON-LD to a nearby script tag. This is the pattern that gets you cited by ChatGPT, see our generative engine optimization checklist for why FAQPage schema is signal #2 in the citation-rate correlation.

7. Astro vs Next.js: the honest comparison

Astro wins for content-first sites. Next.js wins for app shells with per-request personalization. If you can split, do.

This is the split most people get wrong. Both frameworks are excellent. The tradeoff is real and it's about what your site is, not about framework preference.

Option A

Astro

Content-first sites

  • SSG-by-default = faster crawler indexing + higher AI-engine citation rates.
  • Content Collections enforce typed frontmatter at build.
  • Near-zero client JavaScript. Default output is complete HTML.
  • Best for: 90% marketing + blog + docs, small login link out to an external app.

Option B

Next.js

Product-first sites

  • App Router streaming + Suspense = powerful for authenticated dashboards + real-time views.
  • React Server Components let you mix static and dynamic in one page.
  • Mature app ecosystem: auth, ORM adapters, third-party integrations.
  • Best for: marketing + product in one repo, shared components across both.
What acquires users?CONTENT or PRODUCTCONTENTPRODUCTAstroSSG · 0 JS · fast SEOContent Collectionse.g. tourkit.usNext.jsRSC · streaming · app-firstApp Router + authe.g. citeclip.comOR, IF YOU CAN AFFORD TWO REPOSAstro for marketing · Next.js for app
The tradeoff is real. Content-first sites get more SEO leverage from Astro’s static-first defaults; product-first sites get more velocity from Next.js’s app ecosystem.

If you must pick one for a solo SaaS starting today: content-first (blog is the acquisition channel, product is secondary or later) points to Astro. Product-first (app is the acquisition surface, blog exists but isn't the moat) points to Next.js. As of July 2026 tourkit.us is Astro; citeclip.com is Next.js. Both work.

8. What to do this week

One day of setup gets you 90% of the technical SEO surface. Then the work is content, not framework.

If you're on Astro:

  1. 1Install @astrojs/sitemap and add your site URL to astro.config.mjs.
  2. 2Build BaseLayout.astro with the head-tag pattern above (~80 lines).
  3. 3Move your blog to Content Collections with a Zod-typed schema.
  4. 4Deploy to Vercel or Netlify with edge caching enabled.

That's 90% of the technical SEO surface for a content site. Then apply the solo-SaaS SEO five-page framework to decide what to publish inside it. The mechanical layer here doesn't help if the content isn't right.

CiteClip drafts SEO + GEO-ready articles ready to drop into a Content Collections folder (with typed frontmatter, TL;DR, FAQ, and JSON-LD baked in). Sign up at citeclip.com, the first 4 articles are free, no credit card required.

Frequently asked

Is Astro good for SEO?
Yes, Astro is arguably the strongest choice for content-first SEO as of July 2026. Static generation by default means every page ships as complete HTML on first byte, which Googlebot indexes faster and AI-search engines (Perplexity, ChatGPT, Claude) parse without needing to execute JavaScript. Our test set showed JS-heavy SPAs got cited by Perplexity at roughly one-third the rate of static HTML sites with identical content.
How do I add a sitemap to an Astro site?
Install @astrojs/sitemap and add it to the integrations array in astro.config.mjs. Set site: 'https://yourdomain.com' in the config (required for URL generation). Rebuild and you get /sitemap-index.xml automatically listing every static route. Add filter options for drafts, admin, or preview paths.
Should I use Astro or Next.js for a new SaaS?
Content-first (blog is the acquisition channel, product is secondary) points to Astro for the marketing side. Product-first (app is the acquisition surface) points to Next.js. If you can afford two repos, split them: Astro for marketing gets you higher AI-engine citation rates; Next.js for the app gets you the mature streaming + auth ecosystem.
How do I add JSON-LD structured data in Astro?
Render a <script type="application/ld+json"> tag inside your BaseLayout.astro's <head> using Astro's set:html directive (equivalent to React's dangerouslySetInnerHTML). Site-wide schema (Organization, WebSite) goes in the layout; per-page schema (Article, FAQPage) goes in the specific layout or page.
How do I set canonical URLs in Astro?
Compute the canonical from Astro.site and Astro.url.pathname in your layout: new URL(Astro.url.pathname, Astro.site).toString(). Pass the result to BaseLayout as a prop and render it as <link rel="canonical">. Pick a trailing-slash policy in astro.config.mjs and match your deploy platform to prevent duplicate-content splits.

Keep reading


Get found in ChatGPT, Perplexity, Gemini. And Google.

We watch your competitors, find the topics you're missing, and draft the articles those engines recommend. One click sends them to WordPress.