Skip to main content
aapp-hacks
← Back to articles
Author
app-hacks
Published
Updated
Reviewed

Astro / SEO / OGP

Designing Reusable SEO Meta Tags and OGP Images in Astro

Manage canonical URLs, meta descriptions, Open Graph data, and Twitter cards safely in one Astro component for both static and article pages.

Writing metadata directly in every Astro page makes title separators, canonical URL construction, and Open Graph fields drift over time. A shared SEO component can accept only page-specific values as props and apply one rule set to static pages and articles.

Metadata does not automatically raise rankings. It describes the page accurately to search results and sharing surfaces and identifies the canonical URL. Titles and descriptions should stay concise, specific, and consistent with visible content.

Versions and verification scope

This article was checked on September 11, 2026 with Astro 7.3.2 and Node.js 22.22.2 in this repository. SEOHead.astro emitted title, description, canonical, OGP, and Twitter card metadata, adding image tags only when an image prop existed. No shared real image had been added, so the generated HTML did not contain og:image or twitter:image.

Separate site-wide values

export const SITE = {
  name: 'app-hacks',
  title: 'app-hacks | Practical Web Development Notes',
  description: 'A technical blog about reusable web development design and implementation.',
  url: 'https://app-hacks.net',
} as const;

Use the same URL in astro.config.mjs because sitemap generation and Astro.site depend on it:

import { defineConfig } from 'astro/config';
export default defineConfig({ site: 'https://app-hacks.net' });

For one production origin, a constant is simpler than an environment variable. If the temporary domain changes before launch, update both locations together.

Build the SEOHead component

---
import { SITE } from '../consts';

interface Props {
  title?: string;
  description?: string;
  canonical?: string | URL;
  type?: 'website' | 'article';
  image?: string | URL;
}

const { title = SITE.title, description = SITE.description,
  canonical = new URL(Astro.url.pathname, Astro.site ?? SITE.url),
  type = 'website', image } = Astro.props;
const canonicalUrl = canonical instanceof URL ? canonical : new URL(canonical, Astro.site ?? SITE.url);
const imageUrl = image ? image instanceof URL ? image : new URL(image, Astro.site ?? SITE.url) : undefined;
---

<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:site_name" content={SITE.name} />
{imageUrl && <meta property="og:image" content={imageUrl} />}
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
{imageUrl && <meta name="twitter:image" content={imageUrl} />}

The URL constructor turns relative canonical and image paths into absolute URLs. Avoid Astro.url.origin as the production source because it becomes localhost in development and can reflect an unintended proxy host. If no real image exists, omit image tags instead of publishing a URL that returns 404.

Call it from BaseLayout

---
import SEOHead from '../components/SEOHead.astro';
const props = Astro.props;
---
<!doctype html>
<html lang="en">
  <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /><SEOHead {...props} /></head>
  <body><main><slot /></main></body>
</html>

Static pages pass their title and description; article layouts pass frontmatter plus the article canonical and type.

<BaseLayout title={`${post.data.title} | app-hacks`}
  description={post.data.description}
  canonical={`/en/blog/${post.id}/`} type="article">
  <Content />
</BaseLayout>

Inspect generated HTML rather than only the browser view. Every page should contain one title, description, canonical, and OGP set with no localhost origins, empty descriptions, doubled slashes, or nonexistent image paths.

Structured data and verification

The implementation described here outputs BlogPosting using the real article title, author credit, publication date, and update date, plus a BreadcrumbList from home to the article. When no update date exists, the publication date is used as dateModified. It does not invent missing authors, dates, or images.

After nvm use, run npm run build and inspect dist/blog/.../index.html for JSON-LD, canonical, and OGP output. Keep image metadata absent until the actual image exists.