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.
| Tag | Purpose | Consumed by |
|---|---|---|
<title> | Tab label and primary search result heading | Search engines, browsers |
<meta name="description"> | Snippet shown under the search result | Search engines |
<link rel="canonical"> | Declares the authoritative URL for the page | Search engines |
og:title, og:description | Title/summary in social previews | Open Graph readers |
og:image | Preview image (absolute URL) | Open Graph readers |
og:type, og:url | Content type and canonical social URL | Open Graph readers |
twitter:card | Card layout (summary_large_image, etc.) | Twitter/X |
Open Graph
og:imageand 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
siteinastro.config.mjs(e.g.site: "https://devcraftly.com") orAstro.siteisundefinedand thenew 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.astrocomponent and render it from the base layout — never hand-write meta tags on individual pages. - Make
titleanddescriptionrequired props so the build flags any page that forgets them. - Resolve
og:imageand canonical URLs withnew URL(path, Astro.site)to guarantee absolute URLs. - Always set
siteinastro.config.mjs; without it, canonical and social URLs cannot be built. - Ship a default
og-default.pngso 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.