Astro SEO: fast by default, and the layout work that remains

You picked Astro for speed and control. Good choice. Here is what Astro gives you for SEO, what it does not, and the layout pattern that closes the gaps.

By , founder of Porteur · Updated 14 September 2026 · Markdown

What Astro gives you for SEO

Astro renders static HTML by default. Crawlers see your content without running JavaScript. That helps indexing.

It ships zero JavaScript by default. You opt in per island. That keeps pages light and fast when you avoid heavy client code.

It has built-in image optimisation. You can output responsive images with width, quality and format set at build time. That helps LCP and CLS.

It has content collections. You can type front matter and enforce fields across posts or docs. That keeps metadata consistent.

There is an official sitemap integration: @astrojs/sitemap. You add it to your config. It builds your XML sitemap at build time.

What Astro does not ship

Astro has no built-in SEO layer. There is no automatic title or description system. You write them in your layout head.

There is no automatic canonical tag. You add a canonical link in your layout. It should point to the preferred URL for each page.

There is no sitemap unless you add @astrojs/sitemap. Install it and set your site URL. Without it, Search Console has to find pages by crawl alone.

There is no default robots.txt. You place a robots.txt file in public if you need one. Check the platform's current settings if in doubt.

The base layout for titles and descriptions

Put your SEO logic in one layout. Every page uses it. You pass a title and a description in front matter or props.

---
// src/layouts/BaseLayout.astro
export interface Props {
  title?: string
  description?: string
  canonicalPath?: string // e.g. '/pricing'
  noindex?: boolean
  ogImage?: string
}

const {
  title: pageTitle,
  description: pageDescription,
  canonicalPath,
  noindex = false,
  ogImage
} = Astro.props as Props

const site = Astro.site?.toString().replace(/\/$/, '') || '' // from astro.config.mjs

// Compose the full title: 'Page Title | Your Product'
// Keep titles around 60 characters
const siteName = 'Your Product'
const title = pageTitle ? `${pageTitle} | ${siteName}` : siteName

// Meta description around 120 to 155 characters
const description = pageDescription || 'Short description of your product or page.'

const canonical = canonicalPath ? `${site}${canonicalPath}` : `${site}${Astro.url.pathname}`
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <title>{title}</title>
    <meta name="description" content={description} />

    <link rel="canonical" href={canonical} />

    {noindex && <meta name="robots" content="noindex, nofollow" />}

    {/* Open Graph and Twitter for social cards */}
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:type" content="website" />
    <meta property="og:url" content={canonical} />
    {ogImage && <meta property="og:image" content={ogImage} />}
    <meta name="twitter:card" content="summary_large_image" />
  </head>
  <body>
    <slot />
  </body>
</html>

Set Astro.site in astro.config.mjs. The sitemap integration needs it. Your canonical tag uses it too.

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

export default defineConfig({
  site: 'https://yourproduct.com',
  integrations: [sitemap()],
})

Use the layout in pages. You pass page-specific fields. Keep the pattern the same across the site.

---
// src/pages/pricing.astro
import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
  title="Pricing"
  description="Pick a plan. Start in minutes."
  canonicalPath="/pricing"
  ogImage="https://yourproduct.com/og/pricing.png"
>
  <main>
    <h1>Pricing</h1>
    <!-- Content -->
  </main>
</BaseLayout>

A repeatable pattern for pages and posts

You want the same fields for every page type. Titles, descriptions, canonical, publish date, modified date, author for posts. One place to render them.

---
// src/layouts/PostLayout.astro
import BaseLayout from './BaseLayout.astro'

export interface Frontmatter {
  title: string
  description: string
  pubDate: string // ISO
  updatedDate?: string // ISO
  author?: string
  tags?: string[]
  ogImage?: string
}

const { frontmatter } = Astro.props as { frontmatter: Frontmatter }
---
<BaseLayout
  title={frontmatter.title}
  description={frontmatter.description}
  canonicalPath={Astro.url.pathname}
  ogImage={frontmatter.ogImage}
>
  <article>
    <header>
      <h1>{frontmatter.title}</h1>
      <p>
        <time dateTime={frontmatter.pubDate}>{new Date(frontmatter.pubDate).toLocaleDateString()}</time>
        {frontmatter.updatedDate && (
          <span> · Updated <time dateTime={frontmatter.updatedDate}>{new Date(frontmatter.updatedDate).toLocaleDateString()}</time></span>
        )}
      </p>
    </header>
    <slot />
  </article>
</BaseLayout>

A fixed page like /about can use BaseLayout direct. A blog post uses PostLayout. The head stays consistent, the content differs by slot content.

Content collections keep metadata typed

Use content collections for posts, guides or changelogs. You define a schema. Astro validates front matter at build time. Bad entries fail the build, which is good for SEO hygiene.

// src/content/config.ts
import { defineCollection, z } from 'astro:content'

const posts = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string().max(155),
    pubDate: z.string(),
    updatedDate: z.string().optional(),
    author: z.string().default('Team'),
    tags: z.array(z.string()).default([]),
    ogImage: z.string().url().optional(),
  }),
})

export const collections = { posts }
---
// src/content/posts/getting-started.md
// Front matter must match the schema
Title: Getting started with Your Product
Description: A simple guide to set up Your Product and ship your first task.
PubDate: 2026-07-12
UpdatedDate: 2026-09-13
Tags: ["setup", "guide"]
OgImage: https://yourproduct.com/og/guides/getting-started.png
---

## First steps

Content here.

Render posts with a route like /guides/[slug].astro. Pull entries from collections and pass their front matter to PostLayout. Every post gets a valid title and description by design.

Sitemap and canonical URLs

Add @astrojs/sitemap. Set site in the config. The integration crawls your routes at build time and writes sitemap.xml. Submit that in Search Console.

  1. Install the integration

    npm i @astrojs/sitemap

  2. Enable it in astro.config.mjs

    import sitemap from '@astrojs/sitemap' and add sitemap() to integrations. Set site: 'https://yourproduct.com'.

  3. Exclude routes you do not want indexed

    Pass options like exclude: ['/drafts/**'] if needed.

  4. Build and check the output

    Run the build. Open dist/sitemap.xml. Check URLs, lastmod and priority if you set them.

  5. Submit in Search Console

    In Search Console, open Sitemaps, add https://yourproduct.com/sitemap.xml, and watch the status.

Set one canonical per page. Point to the preferred URL, with or without trailing slash, and the right casing. Avoid query strings in canonicals unless the parameter defines content.

// In BaseLayout.astro, already shown
<link rel="canonical" href={canonical} />

Example: your /guides/getting-started page must canonicalise to https://yourproduct.com/guides/getting-started. Avoid duplicates at /guides/getting-started/ or with ?ref parameters. Redirect stray variants at the host level too.

Robots and indexing controls

Serve a robots.txt from public/robots.txt. Disallow drafts, private routes or staging hosts. Keep the production site crawlable. Add a reference to your sitemap URL.

# public/robots.txt
User-agent: *
Disallow: /drafts/
Allow: /
Sitemap: https://yourproduct.com/sitemap.xml

For specific pages, add a robots meta. Use noindex on thin or temporary pages. Example: /changelog/2021-migration-test should not index. Pass noindex to the layout on that page only.

Images and speed on Astro

Use Astro’s image component for hero images. Optimise width, format and quality. Aim for Largest Contentful Paint under 2.5 seconds as of 2026.

---
import { Image } from 'astro:assets'
import hero from '../assets/hero.png'
---
<Image src={hero} alt="Dashboard screenshot" widths={[512, 768, 1024, 1280]} sizes="(max-width: 768px) 100vw, 1280px" format="webp" quality={70} loading="eager" />

Prevent layout shift. Set explicit width and height where you can. Keep Cumulative Layout Shift under 0.1. Avoid CSS that moves content after load.

Keep client JavaScript small. Hydrate only what you need. Target Interaction to Next Paint under 200 ms. Heavy islands and third-party scripts will slow it down.

Test with PageSpeed Insights. Field data shows Core Web Vitals from the last 28 days when there is traffic. Lighthouse in the same tool is one lab run and can vary.

Structured data: JSON-LD in your layouts

Add JSON-LD in the head for rich results. Start with Organization on the home page, then Article for posts. Keep it in the layout so it stays consistent.

---
// In PostLayout.astro head section, after meta tags
const articleLd = {
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: frontmatter.title,
  description: frontmatter.description,
  datePublished: frontmatter.pubDate,
  dateModified: frontmatter.updatedDate || frontmatter.pubDate,
  author: frontmatter.author ? { '@type': 'Person', name: frontmatter.author } : undefined,
  mainEntityOfPage: canonical,
}
---
<script type="application/ld+json">{JSON.stringify(articleLd)}</script>

Validate with the Rich Results Test. Fix missing required fields. Example: an Article needs a headline and a datePublished to qualify for some features as of 2026. If you are not sure, keep the schema small and accurate.

The Astro SEO checklist

  • Set site in astro.config.mjs to your HTTPS root
  • Install and configure @astrojs/sitemap, then submit sitemap.xml in Search Console
  • Create a BaseLayout that sets title, meta description, canonical and social cards
  • Pass canonicalPath or compute it from Astro.url.pathname in layouts
  • Add robots.txt in public with a link to the sitemap and no Disallow for live routes
  • Add noindex on drafts and thin pages only
  • Use content collections with a schema that caps description at 155 characters
  • Render JSON-LD for Organization on / and Article on posts
  • Use astro:assets to optimise hero and product images, set sizes and formats
  • Avoid client-only rendering of primary content
  • Hydrate only the components that need interactivity
  • Keep LCP under 2.5 s, CLS under 0.1 and INP under 200 ms as of 2026
  • Add internal links to key pages from the home page and from related content
  • Test titles are within about 60 characters and read well on mobile
  • Use trailing slashes or not, but be consistent, redirect the other form and set canonicals
  • Check PageSpeed Insights for field data, then fix slow URLs first

A fixed page like /pricing should load fast, with a clear H1, a concise title, a description around 120 to 155 characters and a canonical to the clean URL. A blog post should show dates, an author, next steps and valid Article JSON-LD. Both should sit in your sitemap and be linked from somewhere useful.

Questions

Sources

Check my site, free

Paste your home page URL to see where your Astro site stands: the free check reads your site, the searches around it and rivals on them in about thirty seconds and shows three findings whole.

  • Free check, no card
  • Read-only, your own accounts
  • Readable by your agent

Read next