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.