SEO in Astro is not difficult, but it is tedious. Every page needs a title, a description, a canonical URL, Open Graph tags, Twitter Card tags, and maybe a robots directive — all crammed into <head>. Do that by hand across a dozen pages and you will inevitably miss something, or let a stale title slip through a copy-paste.
astro-seo is a small, focused Astro component that wraps all of those tags behind a single <SEO /> element. You pass props; it emits correct, well-formed tags. The current version is 1.1.0, which requires Astro 5.16 or higher. If you are still on Astro 4, pin to v0.8.4.
Installation
Install the package from npm:
npm install astro-seo
That is all. There is no Astro integration to register, no astro.config.mjs change needed. The package exports a single SEO component you import directly.
Basic Usage in a Layout
The right place to put <SEO /> is inside the <head> of your base layout, not on individual pages. You pass page-specific data down as props from the calling page, and the layout forwards them to the component.
Here is a minimal layout:
---
// src/layouts/BaseLayout.astro
import { SEO } from "astro-seo";
interface Props {
title: string;
description: string;
canonical?: string;
image?: string;
}
const { title, description, canonical, image } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<SEO
title={title}
description={description}
canonical={canonical ?? Astro.url.href}
openGraph={{
basic: {
title: title,
type: "website",
image: image ?? "/og-default.png",
url: canonical ?? Astro.url.href,
},
optional: {
siteName: "Your Site Name",
},
}}
twitter={{
card: "summary_large_image",
site: "@yourhandle",
image: image ?? "/og-default.png",
imageAlt: title,
}}
/>
</head>
<body>
<slot />
</body>
</html>
A page that uses this layout only needs to supply the values it knows:
---
// src/pages/about.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout
title="About Us"
description="We build production-ready Astro themes for local service businesses."
canonical="https://example.com/about"
>
<main>
<h1>About Us</h1>
</main>
</BaseLayout>
Every page that uses BaseLayout gets correct SEO tags with no additional work.
The Title Template
Consistent, structured titles — "Page Name — Site Name" — are good practice. Rather than concatenating strings in every page, use the titleTemplate prop. The %s placeholder is replaced with whatever you pass as title:
<SEO
title="About Us"
titleTemplate="%s — Example Co"
titleDefault="Example Co"
description="..."
/>
This outputs <title>About Us — Example Co</title>. The titleDefault prop is the fallback rendered when no title is provided — useful for your homepage or any page that intentionally skips a page-level title.
Canonical URLs
Canonical tags tell search engines which URL is the authoritative version of a page — essential if your content is accessible at multiple paths (trailing slash vs. no slash, HTTP vs. HTTPS, www vs. non-www).
<SEO
title="Services"
description="Full-service HVAC, repair and maintenance."
canonical="https://example.com/services"
/>
If you are using Astro's site config, you can derive the canonical dynamically:
const canonical = new URL(Astro.url.pathname, Astro.site).href;
Then pass that canonical constant into the component. The removeTrailingSlashForRoot boolean (added in v1.1.0) strips the trailing slash from root-level canonical URLs if your preferred form is https://example.com rather than https://example.com/.
Open Graph Tags
The openGraph prop accepts a structured object that mirrors the Open Graph spec. The basic key is required; optional, image, and article are opt-in.
<SEO
title="How to Choose an HVAC System"
description="A practical guide to sizing and selecting HVAC equipment."
openGraph={{
basic: {
title: "How to Choose an HVAC System",
type: "article",
image: "https://example.com/images/hvac-guide.jpg",
url: "https://example.com/blog/hvac-guide",
},
optional: {
description: "A practical guide to sizing and selecting HVAC equipment.",
locale: "en_US",
siteName: "Example Co",
},
image: {
width: 1200,
height: 630,
alt: "An HVAC technician inspecting a rooftop unit",
},
article: {
publishedTime: "2026-01-15T00:00:00Z",
authors: ["https://example.com/authors/jane"],
tags: ["HVAC", "home improvement"],
},
}}
/>
The type field in basic accepts any valid Open Graph type string: "website" for most pages, "article" for blog posts, "profile" for author pages, and so on. Setting type: "article" is what unlocks the article sub-object.
Twitter Cards
Twitter (X) reads Open Graph as a fallback, but explicit Twitter Card tags give you precise control over how your content appears when shared. Pass the twitter prop:
<SEO
title="HVAC Installation Guide"
description="..."
twitter={{
card: "summary_large_image",
site: "@yoursite",
creator: "@authorhandle",
title: "HVAC Installation Guide",
image: "https://example.com/images/hvac-guide.jpg",
imageAlt: "A technician installing a split-system unit",
description: "Step-by-step guidance for HVAC installation projects.",
}}
/>
The four valid values for card are "summary", "summary_large_image", "app", and "player". For most blog posts and marketing pages, "summary_large_image" gives the best visual result in timelines.
Controlling Robots
The noindex and nofollow booleans map directly to <meta name="robots">:
<SEO
title="Thank You"
description="Your form has been submitted."
noindex={true}
nofollow={true}
/>
Both default to false, so pages are indexable and followable by default. Version 1.1.0 added noarchive and nocache booleans for finer-grained control, plus a robotsExtras string for any directive the component does not expose directly — passed as a comma-separated string and appended to the robots meta value.
Adding Custom Tags with extend
Sometimes you need tags that are not covered by the named props — a preconnect link hint, a theme-color meta, or a third-party verification tag. The extend prop accepts arrays of link and meta objects, each typed as the attributes you would put on the element:
<SEO
title="Home"
description="..."
extend={{
link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
],
meta: [
{ name: "theme-color", content: "#1a56db" },
{ name: "google-site-verification", content: "abc123" },
],
}}
/>
This covers virtually every edge case without needing a second component or raw <head> tags scattered through your templates.
Language Alternates (hreflang)
If your site serves multiple languages, the languageAlternates prop emits the <link rel="alternate" hreflang="..."> tags search engines use for international targeting:
<SEO
title="Home"
description="..."
languageAlternates={[
{ hreflang: "en", href: "https://example.com/" },
{ hreflang: "fr", href: "https://example.com/fr/" },
{ hreflang: "x-default", href: "https://example.com/" },
]}
/>
Putting It Together in a Content Collection Page
A realistic pattern for blog posts driven by Astro content collections looks like this:
---
// src/pages/blog/[slug].astro
import { getCollection } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { title, description, image, publishedDate } = post.data;
const canonical = new URL(`/blog/${post.slug}`, Astro.site).href;
---
<BaseLayout
title={title}
description={description}
canonical={canonical}
image={image}
>
<article>
<h1>{title}</h1>
<!-- post content -->
</article>
</BaseLayout>
The layout already knows how to build the Open Graph and Twitter tags from the props you pass down. The post page itself stays clean and declarative.
What astro-seo Does Not Handle
The component covers head-level SEO meta. A few related concerns live elsewhere:
- JSON-LD structured data — use a companion package like astro-seo-schema, which wraps the schema-dts TypeScript definitions and emits a
<script type="application/ld+json">block. - Sitemap and robots.txt — the official
@astrojs/sitemapintegration handles these; configure it inastro.config.mjs. - Image optimization — Open Graph images need to be real, properly sized files (1200×630 px is conventional for
summary_large_image). astro-seo just emits the URL you give it; it does not validate dimensions or process images.
A Note on Version Compatibility
Version 1.0.0 bumped the minimum Astro requirement to 5.16. If your project is on Astro 4.x, install the 0.x line:
npm install [email protected]
The API is almost identical across versions; the main differences are internal and relate to how Astro handles head injection. Check the CHANGELOG before upgrading between major versions.
Wrapping Up
astro-seo earns its place in most Astro projects by turning a fragile, repetitive pattern into a typed, centralized component. You define defaults once in your layout, override them per-page where needed, and let the component handle the tag construction. The extend prop means you rarely need to fall back to raw <head> tags at all. Install it, wire it into your layout, and move on to the things that actually require your attention.