Skip to content
Astro as seo 4 min read

Meta Tags & the Head

The contents of your document’s <head> decide how a page looks in search results, how it previews when shared on social platforms, and how browsers and crawlers interpret it. Astro renders this metadata to static HTML at build time, so it ships with zero client-side JavaScript and is fully visible to crawlers. The trick to keeping it sane across hundreds of pages is to stop hand-writing tags per page and instead funnel everything through one reusable SEO.astro component. This page builds that component and shows how each page feeds it data.

The tags that matter

A robust head is more than a <title>. Search engines read the title and description; social networks (Facebook, LinkedIn, Slack, Discord) read Open Graph tags; Twitter/X reads its own twitter:* tags. Each group has a small set of fields worth setting on every page.

TagPurposeConsumed by
<title>Tab label and primary search result headingSearch engines, browsers
<meta name="description">Snippet shown under the search resultSearch engines
<link rel="canonical">Declares the authoritative URL for the pageSearch engines
og:title, og:descriptionTitle/summary in social previewsOpen Graph readers
og:imagePreview image (absolute URL)Open Graph readers
og:type, og:urlContent type and canonical social URLOpen Graph readers
twitter:cardCard layout (summary_large_image, etc.)Twitter/X

Open Graph og:image and Twitter image URLs must be absolute (including the origin). Relative paths are silently ignored by most scrapers, leaving you with a blank preview.

A reusable SEO component

Centralize every tag in src/components/SEO.astro. It accepts typed props, computes sensible defaults, and resolves the canonical and image URLs against the deployed site using Astro.site and Astro.url.

---
// src/components/SEO.astro
interface Props {
  title: string;
  description: string;
  image?: string;
  type?: "website" | "article";
}

const {
  title,
  description,
  image = "/og-default.png",
  type = "website",
} = Astro.props;

// Astro.site comes from `site` in astro.config; Astro.url is the current page.
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const socialImage = new URL(image, Astro.site);
---

<!-- Primary -->
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />

<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={socialImage} />

<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />

Because the component script runs only on the server, none of this logic reaches the browser — the output is plain, static <meta> tags. The new URL(path, Astro.site) pattern is the idiomatic way to turn a relative path into the absolute URL that social scrapers require.

Set site in astro.config.mjs (e.g. site: "https://devcraftly.com") or Astro.site is undefined and the new URL() calls throw at build time.

Wiring it into the layout

The <head> lives in your base layout, so that is where the SEO component belongs. The layout takes the same props and forwards them, keeping the document shell as the single source of truth.

---
// src/layouts/BaseLayout.astro
import SEO from "../components/SEO.astro";

interface Props {
  title: string;
  description: string;
  image?: string;
}

const { title, description, image } = Astro.props;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <SEO title={title} description={description} image={image} />
  </head>
  <body>
    <slot />
  </body>
</html>

A page now supplies metadata once, declaratively:

---
// src/pages/index.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---

<BaseLayout
  title="DevCraftly — Modern web docs"
  description="Practical, runnable guides for building fast websites with Astro."
>
  <main><h1>Welcome</h1></main>
</BaseLayout>

The build emits fully resolved tags:

Output:

<title>DevCraftly — Modern web docs</title>
<meta name="description" content="Practical, runnable guides for building fast websites with Astro.">
<link rel="canonical" href="https://devcraftly.com/">
<meta property="og:title" content="DevCraftly — Modern web docs">
<meta property="og:image" content="https://devcraftly.com/og-default.png">
<meta name="twitter:card" content="summary_large_image">

Driving meta from content collections

For blog posts and docs, the title and description usually already live in frontmatter. Pull them straight from the entry’s parsed data so the head stays in sync with content automatically.

---
// src/pages/blog/[...slug].astro
import { getCollection, render } 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.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<BaseLayout
  title={post.data.title}
  description={post.data.excerpt}
  image={post.data.cover}
>
  <article><Content /></article>
</BaseLayout>

Each post’s frontmatter feeds the SEO component directly, so adding a post requires no manual tag editing.

Best practices

  • Keep exactly one SEO.astro component and render it from the base layout — never hand-write meta tags on individual pages.
  • Make title and description required props so the build flags any page that forgets them.
  • Resolve og:image and canonical URLs with new URL(path, Astro.site) to guarantee absolute URLs.
  • Always set site in astro.config.mjs; without it, canonical and social URLs cannot be built.
  • Ship a default og-default.png so pages without a custom image still preview cleanly.
  • Pull metadata from content-collection frontmatter for blog/docs pages so the head stays in sync with content.
  • Keep descriptions roughly 120–160 characters and titles under ~60 so they are not truncated in search results.
Last updated June 14, 2026
Was this helpful?