@jorgejhms/astro-seo-meta-tags
A drop-in Astro component for SEO meta tags: Open Graph, Twitter Cards, canonical URLs and JSON-LD structured data — all from a single <MetaTags />.
- One component, all the
<head>tags you usually need. - Pass a single
metadataobject — ideal for data coming from a CMS, frontmatter or an API. - Open Graph + Twitter Card support out of the box (Twitter falls back to OG, so you only set what differs).
- Canonical URL resolved automatically from
Astro.site(or an explicit override), with query params (UTM, …) stripped by default. - Canonical omitted automatically when
noindexis set (per Google's recommendation). - Robots enriched by default (
max-snippet:-1,max-image-preview:large,max-video-preview:-1), fully overridable. - Generic JSON-LD via the
jsonLdprop for any schema.org type (Product, Article, Organization, …). Relativeimage/logo/urlvalues are resolved to absolute URLs against yoursite. - Safe JSON-LD embedding (escapes
</script>breakouts). - Dev-only SEO warnings:
console.warnwhen your<title>or meta description exceed Google's recommended length — caught while you write, stripped from production. - SSR & Edge-safe by design: no
fs, nochild_process, no build-time integration — renders identically in SSG, SSR (Node), and Edge runtimes. - Published as native
.astro+.ts(no build step, per Astro's packaging guide).
Installation
npm install @jorgejhms/astro-seo-meta-tags
# or: pnpm add / yarn add / bun add
astro is a peer dependency (Astro 4, 5, 6, or 7).
Usage
Set it up in your base layout
The most common pattern: render <MetaTags /> once inside your site's base layout, then pass page-specific metadata to that layout from each page. The layout just forwards its metadata prop straight to the component — no duplication, no boilerplate per page.
1. Create a base layout — src/layouts/BaseLayout.astro:
---
import { MetaTags, type Metadata } from "@jorgejhms/astro-seo-meta-tags";
interface Props {
metadata: Metadata;
}
const { metadata } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<MetaTags metadata={metadata} />
</head>
<body>
<slot />
</body>
</html>
2. Pass metadata from each page — src/pages/index.astro:
---
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout
metadata={{
title: "Kind of Blue — Miles Davis (180g Vinyl)",
titleTemplate: "%s | Example Records",
description:
"1959 jazz masterpiece, 180g audiophile pressing. In stock, ships worldwide.",
featuredImage: "https://example.com/img/kind-of-blue.jpg",
ogImageWidth: 1200,
ogImageHeight: 675,
ogImageAlt: "Kind of Blue album cover",
twitter: { card: "summary_large_image", site: "@examplerecords" },
ogProperties: { locale: "en_US", site_name: "Example Records" },
}}
>
<!-- page content -->
</BaseLayout>
Set
siteinastro.config.mjsso canonical URLs — and relative JSON-LD images — resolve to absolute URLs automatically.
Pulling metadata from a CMS, frontmatter or an API? The
metadataobject is plain data, so you can fetch it anywhere and pass it straight through — including intojsonLd(see below).
Images
featuredImage (rendered as og:image) accepts the three image sources Astro documents and always resolves to an absolute URL (required by social crawlers):
| Source | What you pass | Resolved to |
|---|---|---|
Imported (src/) |
the ImageMetadata import |
image.src → absolute against site |
public/ |
a root-relative path | "/og.jpg" → absolute against site |
| External / CDN | a full URL | used as-is |
Imported images are detected automatically; their built-in width/height also populate og:image:width / og:image:height unless you set them explicitly.
---
import BaseLayout from "../layouts/BaseLayout.astro";
import ogImage from "../assets/og.jpg"; // ImageMetadata
---
<BaseLayout
metadata={{
title: "Kind of Blue — Miles Davis",
description: "1959 jazz masterpiece.",
featuredImage: ogImage, // imported → .src resolved, dims auto-filled
// featuredImage: "/og.jpg", // from public/
// featuredImage: "https://cdn.example.com/og.jpg", // external
ogImageAlt: "Kind of Blue album cover",
}}
/>
og:imagemust be absolute. Setsiteinastro.config.mjsso imported andpublic/paths resolve correctly.
Structured data (JSON-LD)
Pass any schema.org object — or an array of them — via jsonLd. Each is rendered in its own sanitized <script type="application/ld+json">. Relative URLs in the standard URL-bearing fields (image, logo, url) are resolved against your site, in any shape: a URL string, a list of strings, or an ImageObject.
---
import BaseLayout from "../layouts/BaseLayout.astro";
const name = "Kind of Blue (180g Vinyl)";
const description =
"Miles Davis — 1959 jazz masterpiece, 180g audiophile pressing.";
const product = {
"@context": "https://schema.org",
"@type": "Product",
name,
description,
image: "/img/kind-of-blue.jpg", // → https://example.com/img/kind-of-blue.jpg
sku: "COL-CL-1355",
offers: {
"@type": "Offer",
url: "/products/kind-of-blue", // → https://example.com/products/kind-of-blue
price: "89.00",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
};
---
<BaseLayout
metadata={{
title: name,
description,
featuredImage: "https://example.com/img/kind-of-blue.jpg",
jsonLd: product,
}}
/>
jsonLd accepts any schema.org type — Article, WebPage, Organization, FAQPage, etc. Need several blocks (e.g. a Product and a BreadcrumbList)? Pass an array: jsonLd={[product, breadcrumbs]}.
You can also resolve/sanitize manually with the exported helpers (e.g. when generating JSON-LD in an API endpoint):
import {
resolveJsonLd,
sanitizeJsonLd,
} from "@jorgejhms/astro-seo-meta-tags/schema";
const resolved = resolveJsonLd(jsonLdObject, "https://example.com");
const safe = sanitizeJsonLd(JSON.stringify(resolved)); // for set:html
SSR & Edge compatibility
The component and its helpers contain no node:*, fs, child_process, or build-time hooks — only Astro.url / Astro.site, the URL constructor, and plain string ops. It therefore renders identically in:
- Static generation (SSG) — the default.
- SSR on Node (e.g.
@astrojs/node). - SSR on the edge (Cloudflare Workers/Pages, Vercel Edge) where Node APIs are unavailable.
Dev-only SEO length warnings
Google truncates long titles and meta descriptions in search results (by display width, not a fixed character count). While you're developing, <MetaTags /> emits a console.warn when:
- the rendered
<title>exceeds 65 characters, or - the
<meta name="description">exceeds 155 characters.
These warnings are dev-only — guarded by import.meta.env.DEV, so Vite removes them from your production build (no runtime cost, no log noise).
Tune (or disable) the thresholds with environment variables in your .env:
# Defaults shown — set either to 0 to disable that check.
SEO_TITLE_MAX=65
SEO_DESCRIPTION_MAX=155
The numbers aren't Google-mandated: there's no official character limit, and truncation is based on pixel width. 65 / 155 are sensible, widely-used defaults that correspond roughly to what Google typically displays.
Props
<MetaTags metadata={...} />
| Field | Type | Default | Description |
|---|---|---|---|
title |
string |
— (req.) | Page <title>; also og:title. |
titleTemplate |
string |
— | Title template with a %s placeholder, e.g. "%s | My Site". |
description |
string |
— (req.) | Meta description; also og:description. |
keywords |
string |
— | Comma-separated meta keywords. |
author |
string |
— | <meta name="author">. |
copyright |
string |
— | <meta name="copyright">. |
themeColor |
string |
— | <meta name="theme-color"> (e.g. "#ffffff"). |
featuredImage |
string | ImageMetadata |
— | og:image. Imported src/ image, public/ path, or full URL; resolved to an absolute URL. Imported images also fill ogImageWidth/ogImageHeight. |
ogImageWidth |
number | string |
— | og:image:width (recommended for Google Discover). Auto-filled from imported images. |
ogImageHeight |
number | string |
— | og:image:height. Auto-filled from imported images. |
ogImageAlt |
string |
— | og:image:alt. |
type |
string |
"website" |
Open Graph og:type. |
canonicalUrl |
string |
current URL | Canonical URL override. |
siteUrl |
string |
Astro.site |
Base origin for canonical + JSON-LD URL resolution. |
preserveQueryParams |
boolean |
false |
Keep query params on the canonical (default strips UTM, etc.). |
twitter |
TwitterOverride |
— | Twitter Card config. Only set twitter:* tags are emitted. |
twitterCard |
string |
— | (legacy alias for twitter.card). |
twitterSite |
string |
— | (legacy alias for twitter.site). |
ogProperties |
Record<string, string> |
— | Extra og:* properties (e.g. { locale, site_name }). |
jsonLd |
JsonLd |
— | Arbitrary JSON-LD object(s); relative image/logo/url resolved against siteUrl, sanitized, rendered as ld+json. |
robots |
string |
(enriched) | Verbatim robots directive; overrides the computed default. |
noindex |
boolean |
false |
Emit noindex, nofollow (also omits canonical). Ignored if robots is set. |
License
Mozilla Public License Version 2.0 (MPL-2.0).
MPL-2.0 is a weak copyleft license: you can use, modify and distribute this component in projects of any license (including proprietary), but any modifications you make to these component files themselves must be redistributed under MPL-2.0 with source available.