Summit Themes
Blog

Using astro-seo to Simplify SEO in Astro Projects

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/sitemap integration handles these; configure it in astro.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.