Meta Tags and Open Graph in Astro: Build One Reusable SEO Component

Build an Astro SEO component for canonical URLs, meta descriptions, Open Graph, and social cards without hardcoding site-wide values.

Quick answer

How do you add SEO meta tags and Open Graph data to an Astro site?

Build one reusable SEO.astro component that outputs meta description, canonical URL, Open Graph, and Twitter Card tags from props — then drive every site-specific value (name, URL, default description) from a single config.ts file instead of hardcoding it inside the component.

Astro SEO component output validated with og:title, og:description and og:image meta tags in browser DevTools
First-hand experience: Based on direct hands-on use. Built live on astro-content-lab.vercel.app. Every bug in this article happened in the order described, not staged for the writeup.

I went into this wanting more than “add a title tag and call it SEO.” The actual goal was a system — clone the repo, fill in frontmatter on a new post, and the SEO output should just work. No hunting through component files to wire up a new page’s meta tags by hand.

That goal is what turned a simple meta-tags task into a small refactor of how the whole project handles site-wide values.


Building the SEO Component

Astro does not ship a project-specific SEO component, which is fair. Every site needs a slightly different set of fields. I built one accepting props for title, description, canonical URL, Open Graph image, and an ogType that distinguishes normal pages from articles.

---
// src/components/SEO.astro
interface Props {
  title: string
  description: string
  canonicalURL?: string
  ogImage?: string
  ogType?: 'website' | 'article'
  noindex?: boolean
}

const {
  title,
  description,
  canonicalURL = Astro.url.href,
  ogImage,
  ogType = 'website',
  noindex = false,
} = Astro.props

const resolvedOgImage = ogImage
  ? new URL(ogImage, Astro.site).href
  : new URL('/og-default.jpg', Astro.site).href
---

<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
{noindex && <meta name="robots" content="noindex, nofollow" />}

<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={resolvedOgImage} />

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:image" content={resolvedOgImage} />

Dropped it into BaseLayout.astro, ran the dev server, hit /blog to check the output.

Crash. [ERROR] Invalid URL.


The site Field Nobody Warns You About

The error traced back to this line:

new URL(ogImage, Astro.site).href

Astro.site was undefined. I had not set a site field in astro.config.mjs. It is optional for a basic Astro project, so I skipped it without thinking. Once your code uses Astro.site to construct absolute URLs, you need to configure it.

// astro.config.mjs
export default defineConfig({
  site: 'https://astro-content-lab.vercel.app',
  // ...
})

The part that tripped me up for a second: why set the production URL while developing locally? Astro.url.href resolves to the current request URL during development. Social preview images need absolute, publicly reachable URLs when external services fetch the deployed page. The configured site value gives Astro a stable production base for those URLs.

Astro also uses site for features and integrations that need the final origin. The official configuration reference is the source to check if this behavior changes.

If the project ever moves to a custom domain, this is the one line that changes.


The Hardcoding I Almost Shipped

With the URL error fixed, I went through each slug page wiring up title, description, and ogImage. Then I looked at what I’d written in BaseLayout:

<title>{post.data.title} — Astro Content Lab</title>

“Astro Content Lab” — typed directly, as a string, baked into the component.

The fix: a single config file holding every site-specific constant.

// src/config.ts
export const SITE_NAME = 'Astro Content Lab'
export const SITE_DESCRIPTION = 'A real content site built with Astro, MDX and Tailwind CSS.'
export const SITE_URL = 'https://astro-content-lab.vercel.app'
export const TWITTER_HANDLE = '@yourtwitterhandle'

Every component that needs the site name imports it instead of typing it:

import { SITE_NAME, SITE_DESCRIPTION } from '../config'

<title>{title} — {SITE_NAME}</title>

Anyone forking the project changes four lines in one file. Every page title, every Open Graph tag, every fallback description updates automatically. This is the same idea as a WordPress site settings page — one place identity lives, every template reads from it instead of hardcoding it.

I tested it with curl right after:

curl -s http://localhost:4321/reviews/hostinger-review | grep '<title'

Still just Hostinger Review 2026 — no SITE_NAME suffix. I’d updated the component but left the actual <title> tag in BaseLayout.astro still reading the raw prop, never wired to the new constant. Fixed that, ran it again, hit SITE_NAME is not defined because I’d referenced the constant without importing it. Small, dumb, fixable — but worth admitting, because the lesson isn’t “I wrote a config file once and everything was perfect.” It’s that wiring a new constant through every place it’s needed takes more than one pass.


The Local Dev og:image That Looks Wrong (And Isn’t)

Testing og:image on a slug page with a local hero image:

curl -s http://localhost:4321/blog/hello-world | grep 'og:image'
og:image content="https://astro-content-lab.vercel.app/@fs/Users/.../hello-world.jpg?origWidth=1200..."

That @fs/Users/... segment is a local filesystem path leaking into a production URL. Looks broken.

It is a development-only path produced while Vite serves a local asset. A production build fingerprints and emits the asset under a public path such as /_astro/hello-world.a1b2c3.webp.

I had two ways to handle this. Write conditional logic to detect dev-mode paths and fall back to the default image, keeping og:image clean in every environment including local dev. Or accept that local dev og:image looks ugly, since production is the only environment that actually matters for social previews — nobody screenshots a localhost Open Graph tag and shares it.

I went with the second option. Adding environment-detection logic to a demo project meant for other people to learn from adds complexity that doesn’t teach anything useful. The bug only exists in an environment nobody but the developer ever sees.


Drawing a Default OG Image

Pages without a hero image — guide pages without one assigned yet, the homepage — fall back to /og-default.jpg. I needed to actually create that file.

First attempt, generated with a quick Python script: solid color background, centered white text, done in under a minute.

It looked bad. Flat, no hierarchy, no personality — exactly what a five-minute placeholder looks like.

Second attempt: dark gradient background, a small “ASTRO SERIES” badge top-left, the site name in two-tier typography (lighter accent color for “Astro,” white for “Content Lab”), a tagline beneath a divider line, a subtle dot-grid pattern on the right side for visual texture, and the URL anchored at the bottom.

That one looked like something I’d actually want showing up when someone shares a link on Twitter.


What’s Live Now

Every page in astro-content-lab now ships with a unique meta description, one canonical URL, Open Graph tags with a per-page image or styled fallback, and matching social-card metadata.

None of it is hardcoded to this specific site. config.ts holds the identity. The SEO.astro component holds the logic. Every slug page just passes its own title, description, and ogImage from frontmatter — nothing more.

That was the goal from the start: build the mechanism once so future me only has to provide accurate page data. The component cannot make a weak title or vague description useful. It can make sure the markup is consistent.

The next article adds structured data without mixing it into this component.


Frequently Asked Questions

Why do I need a site field in astro.config.mjs for Open Graph images to work?
Open Graph images must be absolute URLs — Facebook, Twitter, and Slack crawlers can't resolve relative paths because they don't know your domain. Astro's site config value lets you convert a relative image path into a full production URL with new URL(path, site).
Should the site field point to localhost during development?
No. The site field should always be your production URL. Astro.url.href will still resolve to localhost automatically during local dev for canonical tags, but Open Graph images need a stable, real domain to be useful at all, even while developing locally.
Why does og:image show a strange local file path during development?
During npm run dev, Vite may represent a local image with an @fs development path. That URL is meant for the local server, not social sharing. Run a production build and inspect the deployed HTML before diagnosing the final og:image value; Astro emits the asset under a public build path.
What's the simplest way to avoid hardcoding site name across an Astro project?
Create one config.ts file exporting constants such as SITE_NAME, SITE_URL, SITE_DESCRIPTION, and the default social image. Components and layouts import those values instead of repeating literal strings. A future domain or brand change then happens in one reviewed file rather than across several templates.