npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lucky-media/astro-seo

v0.1.2

Published

Next.js-style generateMetadata for Astro

Readme

@lucky-media/astro-seo

Next.js App Router-style generateMetadata for Astro. Configure site-level SEO defaults once in astro.config.mjs and override per-page with a familiar API.

Table of Contents

  1. Introduction
  2. Installation
  3. Configuration
  4. Setup: BaseLayout.astro
  5. Pattern 1: Static Metadata
  6. Pattern 2: Dynamic Metadata
  7. Pattern 3: Title Templates
  8. Pattern 4: OpenGraph + Twitter
  9. Pattern 5: Parent Inheritance
  10. Pattern 6: Robots + Canonical
  11. Pattern 7: Icons
  12. Pattern 8: CMS Defaults (SSG, fetch at build time)
  13. Pattern 9: CMS Defaults (SSR, fetch at runtime)
  14. Field Reference Table
  15. Limitations

Introduction

Managing <head> metadata in Astro has no built-in convention. Each site ends up reinventing its own props-drilling approach: a layout that accepts a title string, maybe a description, and then stops there. Adding OpenGraph images, Twitter cards, robots directives, canonical URLs, or per-page title templates requires custom plumbing every time.

@lucky-media/astro-seo solves this by bringing Next.js's generateMetadata / Metadata API to Astro. You get:

  • Site-level defaults declared once in astro.config.mjs via the defaultSeo() integration
  • Per-page overrides declared with generateMetadata(), using the same mental model as Next.js App Router
  • Automatic inheritance: every page inherits site defaults; child values override parent values
  • Title templates: %s | My Site applied to every page title automatically
  • Twitter card auto-detection: summary_large_image when images are present, summary otherwise
  • A typed <SEO /> component that renders every supported <meta> and <link> tag

If you know Next.js metadata, you already know this API.


Installation

npm install @lucky-media/astro-seo

Then add the integration to astro.config.mjs:

// astro.config.mjs
import { defineConfig } from 'astro/config'
import { defaultSeo } from '@lucky-media/astro-seo'

export default defineConfig({
  site: 'https://example.com',
  integrations: [
    defaultSeo({
      metadataBase: new URL('https://example.com'),
      title: { default: 'My Site', template: '%s | My Site' },
      description: 'My site description',
    }),
  ],
})

TypeScript / IDE completions

TypeScript types for virtual:@lucky-media/astro-seo are injected automatically by the integration into .astro/integrations/@lucky-media/astro-seo/types.d.ts. No tsconfig.json changes or /// <reference> directives are needed; you get full IntelliSense for generateMetadata, resolveMetadata, Metadata, and ResolvedMetadata out of the box.


Configuration

defaultSeo(config) accepts either a full Metadata object or a function that returns one (sync or async). All fields are optional.

Static config: serialized and embedded in the virtual module at build time. No runtime overhead:

defaultSeo({
  title: { default: 'My Site', template: '%s | My Site' },
  description: 'My site description',
})

Function config: called once during Vite's build, before the virtual module is compiled. Use this to fetch defaults from a CMS or environment at build time:

defaultSeo(async () => {
  const res = await fetch('https://your-cms.com/api/seo-defaults')
  return res.json()
})

Sync functions are also accepted (() => ({ title: '...' })). The result is cached; the function is never called more than once per build.

For per-request runtime defaults (SSR), see Pattern 9.


The full static config reference:

// astro.config.mjs
import { defineConfig } from 'astro/config'
import { defaultSeo } from '@lucky-media/astro-seo'

export default defineConfig({
  integrations: [
    defaultSeo({
      // Base URL used to resolve relative paths for images, icons, and URLs
      metadataBase: new URL('https://example.com'),

      // Plain string: every page gets exactly this title (no template applied)
      // TemplateString: sets a site default AND a template for per-page titles
      title: { default: 'My Site', template: '%s | My Site' },

      description: 'We build great digital products.',

      applicationName: 'My Site',
      generator: 'Astro',
      keywords: ['astro', 'seo', 'metadata'],
      referrer: 'origin-when-cross-origin',
      creator: 'Lucky Media',
      publisher: 'Lucky Media',

      // Robots: string or directive object
      robots: 'index, follow',

      // Canonical and alternate language URLs
      alternates: {
        canonical: 'https://example.com',
        languages: { 'en-US': 'https://example.com/en-us' },
      },

      // Favicon, Apple touch icon, shortcut icon
      icons: {
        icon: '/favicon.ico',
        apple: '/apple-touch-icon.png',
        shortcut: '/shortcut-icon.png',
      },

      // Web app manifest
      manifest: '/site.webmanifest',

      // OpenGraph defaults (used on every page unless overridden)
      openGraph: {
        type: 'website',
        siteName: 'My Site',
        locale: 'en_US',
        images: [{ url: '/og-default.png', alt: 'My Site' }],
      },

      // Twitter card defaults
      twitter: {
        site: '@mysite',
        creator: '@myhandle',
      },

      // Site verification tokens
      verification: {
        google: 'abc123',
        yandex: 'def456',
      },

      // Arbitrary extra meta tags
      other: {
        'theme-color': '#ffffff',
      },
    }),
  ],
})

Setup: BaseLayout.astro

Every page in your site should render through a shared layout. The layout is where resolveMetadata() merges the site config with the page's overrides, and where the <SEO /> component renders the resulting <head> tags.

---
// src/layouts/BaseLayout.astro
import { resolveMetadata } from 'virtual:@lucky-media/astro-seo'
import SEO from '@lucky-media/astro-seo/SEO.astro'
import type { Metadata } from 'virtual:@lucky-media/astro-seo'

interface Props {
  metadata?: Metadata
}

const resolved = resolveMetadata(Astro.props.metadata)
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <SEO metadata={resolved} />
  </head>
  <body>
    <slot />
  </body>
</html>

How it works:

  • resolveMetadata(pageMetadata?) reads the site config baked in by the integration and merges the page's overrides on top
  • <SEO metadata={resolved} /> renders every applicable <meta> and <link> tag from the resolved metadata
  • If no metadata prop is passed, resolveMetadata() still returns the full site defaults

Pattern 1: Static Metadata

For pages with fixed, known content, call generateMetadata() with literal values.

---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import { generateMetadata } from 'virtual:@lucky-media/astro-seo'

const metadata = generateMetadata({
  title: 'Home',
  description: 'Welcome to Lucky Media.',
})
---
<BaseLayout metadata={metadata}>
  <h1>Welcome</h1>
</BaseLayout>

With the title template '%s | My Site' configured at the site level, the page title resolves to "Home | My Site". Unset fields fall through to the site config.


Pattern 2: Dynamic Metadata

For dynamic pages (blog posts, product pages, user profiles), fetch data in the frontmatter and pass it to generateMetadata().

---
// src/pages/blog/[slug].astro
import BaseLayout from '../../layouts/BaseLayout.astro'
import { generateMetadata } from 'virtual:@lucky-media/astro-seo'

export function getStaticPaths() {
  return [
    { params: { slug: 'hello-world' }, props: { title: 'Hello World', excerpt: 'First post.' } },
    { params: { slug: 'design-tips' }, props: { title: 'Design Tips', excerpt: 'Top 10 tips.' } },
  ]
}

const { title, excerpt } = Astro.props

const metadata = generateMetadata({
  title,
  description: excerpt,
  openGraph: {
    type: 'article',
    title,
    description: excerpt,
    images: [{ url: `/og/blog/${Astro.params.slug}.png`, alt: title }],
  },
  twitter: {
    title,
    description: excerpt,
    images: [{ url: `/og/blog/${Astro.params.slug}.png`, alt: title }],
  },
})
---
<BaseLayout metadata={metadata}>
  <article>
    <h1>{title}</h1>
    <p>{excerpt}</p>
  </article>
</BaseLayout>

In SSR mode (output: 'server'), use await in the frontmatter before calling generateMetadata():

---
const { slug } = Astro.params
const post = await fetch(`https://api.example.com/posts/${slug}`).then(r => r.json())

const metadata = generateMetadata({
  title: post.title,
  description: post.excerpt,
})
---

Pattern 3: Title Templates

Title templates let you define a site-wide title suffix once and have every page title formatted automatically.

Site-level template

defaultSeo({
  title: { default: 'Lucky Media', template: '%s | Lucky Media' },
})
  • default: used when a page provides no title at all
  • template: applied to every page title that passes a plain string or TemplateString.default

Per-page title with template applied

const metadata = generateMetadata({ title: 'About' })
// Resolves to: "About | Lucky Media"

Bypassing the template with absolute

const metadata = generateMetadata({
  title: { absolute: 'Lucky Media - Special Campaign' },
})
// Resolves to: "Lucky Media - Special Campaign"

Overriding the template for a subtree

const metadata = generateMetadata({
  title: { default: 'Blog', template: '%s - Lucky Media Blog' },
})
// This page: "Blog"
// Child pages using this layout: "Post Title - Lucky Media Blog"

Pattern 4: OpenGraph + Twitter

Both openGraph and twitter are deep-merged field-by-field with the site defaults. Fields you set on a page override the corresponding site default; fields you omit are inherited. To clear a field entirely, set it to null.

OpenGraph

const metadata = generateMetadata({
  title: 'Our Services',
  openGraph: {
    type: 'website',
    title: 'Our Services - Lucky Media',
    description: 'Full-service digital studio.',
    siteName: 'Lucky Media',
    locale: 'en_US',
    url: 'https://example.com/services',
    images: [
      {
        url: '/og/services.png',
        alt: 'Lucky Media Services',
        width: 1200,
        height: 630,
        type: 'image/png',
      },
    ],
  },
})

HTML output:

<meta property="og:type" content="website" />
<meta property="og:title" content="Our Services - Lucky Media" />
<meta property="og:description" content="Full-service digital studio." />
<meta property="og:site_name" content="Lucky Media" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://example.com/services" />
<meta property="og:image" content="https://example.com/og/services.png" />
<meta property="og:image:alt" content="Lucky Media Services" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/png" />

Twitter

const metadata = generateMetadata({
  title: 'Our Services',
  twitter: {
    card: 'summary_large_image',
    site: '@luckymedia',
    creator: '@luckymedia',
    title: 'Our Services - Lucky Media',
    description: 'Full-service digital studio.',
    images: [{ url: '/og/services.png', alt: 'Lucky Media Services' }],
  },
})

Twitter card auto-detection

You do not need to set card explicitly. The resolver infers it:

| Condition | Resolved twitter:card | |---|---| | twitter.images is set | summary_large_image | | twitter.images is not set | summary |

To use player or app, set card explicitly.


Pattern 5: Parent Inheritance

Because openGraph and twitter are deep-merged, you only need to set the fields that differ from your site defaults. The rest are inherited automatically.

---
// src/pages/products/[id].astro
import BaseLayout from '../../layouts/BaseLayout.astro'
import { generateMetadata } from 'virtual:@lucky-media/astro-seo'

export function getStaticPaths() {
  return [
    { params: { id: '1' }, props: { name: 'Widget Pro', image: '/products/widget-pro.png' } },
    { params: { id: '2' }, props: { name: 'Super Gadget', image: '/products/gadget.png' } },
  ]
}

const { name, image } = Astro.props

const metadata = generateMetadata({
  title: name,
  openGraph: {
    title: name,
    images: [{ url: image, alt: name }], // site default image inherited when omitted
  },
})
---
<BaseLayout metadata={metadata}>
  <h1>{name}</h1>
</BaseLayout>

If you need to read the site-level resolved metadata directly (for example to combine values), resolveMetadata() called with no arguments returns it:

Inheritance rules

| Field | Child behaviour | |---|---| | title | Overrides parent; parent template still applied unless absolute used | | description | Replaces parent value when set | | openGraph | Deep-merged field-by-field; images replaced when set, inherited when omitted | | twitter | Deep-merged field-by-field; images replaced when set, inherited when omitted | | verification | Shallow-merged: child keys extend parent keys | | other | Shallow-merged: child keys extend parent keys | | All other fields | Child value replaces parent when not undefined |


Pattern 6: Robots + Canonical

Robots

String form:

const metadata = generateMetadata({
  robots: 'noindex, nofollow',
})

Object form:

const metadata = generateMetadata({
  robots: { index: false, follow: false },
  // serializes to "noindex, nofollow"
})

const metadata = generateMetadata({
  robots: { index: true, follow: true, nocache: true },
  // serializes to "index, follow, nocache"
})

Canonical URL

const metadata = generateMetadata({
  title: 'About',
  alternates: {
    canonical: 'https://example.com/about',
  },
})

Relative paths are resolved against metadataBase:

const metadata = generateMetadata({
  alternates: {
    canonical: '/about', // resolves to https://example.com/about
  },
})

Alternate language links (hreflang)

const metadata = generateMetadata({
  alternates: {
    canonical: 'https://example.com/about',
    languages: {
      'en-US': 'https://example.com/en-us/about',
      'fr-FR': 'https://fr.example.com/about',
    },
  },
})
<link rel="canonical" href="https://example.com/about" />
<link rel="alternate" hreflang="en-US" href="https://example.com/en-us/about" />
<link rel="alternate" hreflang="fr-FR" href="https://fr.example.com/about" />

Pattern 7: Icons

Simple string paths

defaultSeo({
  icons: {
    icon: '/favicon.ico',
    apple: '/apple-touch-icon.png',
    shortcut: '/shortcut-icon.png',
  },
})
<link rel="icon" href="https://example.com/favicon.ico" />
<link rel="apple-touch-icon" href="https://example.com/apple-touch-icon.png" />
<link rel="shortcut icon" href="https://example.com/shortcut-icon.png" />

Multiple icons with explicit sizes and MIME types

defaultSeo({
  icons: {
    icon: [
      { url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
      { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
    ],
    apple: [
      { url: '/apple-touch-icon-76x76.png', sizes: '76x76' },
      { url: '/apple-touch-icon-120x120.png', sizes: '120x120' },
      { url: '/apple-touch-icon-152x152.png', sizes: '152x152' },
    ],
  },
})

Web app manifest

defaultSeo({
  manifest: '/site.webmanifest',
})
<link rel="manifest" href="https://example.com/site.webmanifest" />

Pattern 8: CMS Defaults (SSG, fetch at build time)

When your site-wide SEO defaults live in a CMS (title, description, OG image, etc.), pass an async function to defaultSeo(). It is called once during the build and the result is baked into the virtual module, exactly like a static config, just fetched dynamically.

// astro.config.mjs
import { defineConfig } from 'astro/config'
import { defaultSeo } from '@lucky-media/astro-seo'

export default defineConfig({
  integrations: [
    defaultSeo(async () => {
      const res = await fetch('https://your-cms.com/api/seo-defaults')
      const data = await res.json()

      return {
        metadataBase: new URL('https://example.com'),
        title: { default: data.siteName, template: `%s | ${data.siteName}` },
        description: data.description,
        openGraph: {
          siteName: data.siteName,
          images: [{ url: data.defaultOgImage, alt: data.siteName }],
        },
      }
    }),
  ],
})

The async function is resolved once during Vite's build phase, before the virtual module is compiled. No CMS calls happen at request time; the resolved config is static for the lifetime of the build.


Pattern 9: CMS Defaults (SSR, fetch at runtime)

For SSR sites where site-wide defaults change between requests (multi-tenant apps, preview modes, A/B testing), pass runtime defaults as the second argument to resolveMetadata(). These are merged between the baked-in site config and page-level metadata.

Merge order:

null baseline → baked-in site config → runtime defaults → page metadata

Runtime defaults override the baked-in config; page metadata still wins over everything.

---
// src/layouts/BaseLayout.astro
import { resolveMetadata } from 'virtual:@lucky-media/astro-seo'
import SEO from '@lucky-media/astro-seo/SEO.astro'
import type { Metadata } from 'virtual:@lucky-media/astro-seo'

interface Props {
  metadata?: Metadata
}

// Fetch CMS defaults per-request; cache this however suits your app
const res = await fetch(`https://your-cms.com/api/seo-defaults?site=${Astro.locals.siteId}`)
const cmsDefaults: Metadata = await res.json()

const resolved = resolveMetadata(Astro.props.metadata, cmsDefaults)
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <SEO metadata={resolved} />
  </head>
  <body>
    <slot />
  </body>
</html>

resolveMetadata() stays synchronous; the async work (the CMS fetch) happens in the Astro frontmatter before resolveMetadata is called. You control caching strategy: store defaults in Astro.locals, a module-level Map keyed by tenant, or any other pattern.

For SSG builds where defaults never change at runtime, use Pattern 8 instead.


Field Reference Table

Core fields

| Field | Example | HTML output | |---|---|---| | title (string) | title: 'Page Title' | <title>Page Title</title> | | title.default | title: { default: 'Site' } | <title>Site</title> | | title.template | title: { template: '%s \| Site' } | Applied to child page titles | | title.absolute | title: { absolute: 'Fixed Title' } | <title>Fixed Title</title> (bypasses template) | | description | description: 'Page description' | <meta name="description" content="..." /> | | applicationName | applicationName: 'My App' | <meta name="application-name" content="..." /> | | authors | authors: [{ name: 'Jane', url: 'https://...' }] | <meta name="author" content="Jane" /> | | generator | generator: 'Astro' | <meta name="generator" content="Astro" /> | | keywords | keywords: ['astro', 'seo'] | <meta name="keywords" content="astro, seo" /> | | referrer | referrer: 'origin-when-cross-origin' | <meta name="referrer" content="..." /> | | creator | creator: 'Lucky Media' | <meta name="author" content="Lucky Media" /> | | publisher | publisher: 'Lucky Media' | <meta name="publisher" content="Lucky Media" /> | | category | category: 'technology' | <meta name="category" content="technology" /> | | metadataBase | metadataBase: new URL('https://example.com') | Used to resolve relative URLs |

Robots

| Field | Example | HTML output | |---|---|---| | robots (string) | robots: 'noindex, nofollow' | <meta name="robots" content="noindex, nofollow" /> | | robots.index | robots: { index: true } | <meta name="robots" content="index" /> | | robots.follow | robots: { follow: false } | <meta name="robots" content="nofollow" /> | | robots.nocache | robots: { nocache: true } | <meta name="robots" content="nocache" /> |

Alternates

| Field | Example | HTML output | |---|---|---| | alternates.canonical | alternates: { canonical: '/page' } | <link rel="canonical" href="..." /> | | alternates.languages | alternates: { languages: { 'en-US': '/en' } } | <link rel="alternate" hreflang="en-US" href="/en" /> |

Icons

| Field | Example | HTML output | |---|---|---| | icons.icon (string) | icons: { icon: '/favicon.ico' } | <link rel="icon" href="..." /> | | icons.icon (object) | icons: { icon: { url: '/favicon.png', sizes: '32x32', type: 'image/png' } } | <link rel="icon" href="..." type="image/png" sizes="32x32" /> | | icons.apple | icons: { apple: '/apple-touch-icon.png' } | <link rel="apple-touch-icon" href="..." /> | | icons.shortcut | icons: { shortcut: '/shortcut.ico' } | <link rel="shortcut icon" href="..." /> | | manifest | manifest: '/site.webmanifest' | <link rel="manifest" href="..." /> |

OpenGraph

| Field | Example | HTML output | |---|---|---| | openGraph.type | openGraph: { type: 'article' } | <meta property="og:type" content="article" /> | | openGraph.title | openGraph: { title: 'Title' } | <meta property="og:title" content="Title" /> | | openGraph.description | openGraph: { description: '...' } | <meta property="og:description" content="..." /> | | openGraph.url | openGraph: { url: 'https://...' } | <meta property="og:url" content="..." /> | | openGraph.siteName | openGraph: { siteName: 'My Site' } | <meta property="og:site_name" content="My Site" /> | | openGraph.locale | openGraph: { locale: 'en_US' } | <meta property="og:locale" content="en_US" /> | | openGraph.images[].url | openGraph: { images: [{ url: '/og.png' }] } | <meta property="og:image" content="..." /> | | openGraph.images[].alt | openGraph: { images: [{ url: '...', alt: 'Alt' }] } | <meta property="og:image:alt" content="Alt" /> | | openGraph.images[].width | openGraph: { images: [{ url: '...', width: 1200 }] } | <meta property="og:image:width" content="1200" /> | | openGraph.images[].height | openGraph: { images: [{ url: '...', height: 630 }] } | <meta property="og:image:height" content="630" /> | | openGraph.images[].type | openGraph: { images: [{ url: '...', type: 'image/png' }] } | <meta property="og:image:type" content="image/png" /> |

Twitter

| Field | Example | HTML output | |---|---|---| | twitter.card | twitter: { card: 'summary_large_image' } | <meta name="twitter:card" content="summary_large_image" /> | | twitter.site | twitter: { site: '@mysite' } | <meta name="twitter:site" content="@mysite" /> | | twitter.creator | twitter: { creator: '@handle' } | <meta name="twitter:creator" content="@handle" /> | | twitter.title | twitter: { title: 'Title' } | <meta name="twitter:title" content="Title" /> | | twitter.description | twitter: { description: '...' } | <meta name="twitter:description" content="..." /> | | twitter.images[].url | twitter: { images: [{ url: '/og.png' }] } | <meta name="twitter:image" content="..." /> | | twitter.images[].alt | twitter: { images: [{ url: '...', alt: 'Alt' }] } | <meta name="twitter:image:alt" content="Alt" /> |

Verification

| Field | Example | HTML output | |---|---|---| | verification.google | verification: { google: 'abc123' } | <meta name="google-site-verification" content="abc123" /> | | verification.yandex | verification: { yandex: 'def456' } | <meta name="yandex-verification" content="def456" /> |

Other / custom meta tags

| Field | Example | HTML output | |---|---|---| | other | other: { 'theme-color': '#fff' } | <meta name="theme-color" content="#fff" /> | | other (array) | other: { 'theme-color': ['#fff', '#000'] } | Multiple <meta name="theme-color" .../> tags |


Limitations

File-based static OG images

@lucky-media/astro-seo does not generate OG images. Image paths must point to files that already exist in your public/ directory or are generated by a separate tool. Relative paths are resolved against metadataBase so the final <meta> tag contains an absolute URL.

No streaming or PPR support

resolveMetadata() is a synchronous function. All metadata, including any runtime CMS defaults, must be resolved in the Astro frontmatter before the component renders. Use await in the frontmatter before calling resolveMetadata() or generateMetadata().

No robots.googleBot rendering

The RobotsDirective type includes a googleBot nested directive (mirroring Next.js), but the <SEO /> component currently renders only the top-level <meta name="robots"> tag.

Unrendered fields

verification.yahoo, twitter.siteId, and twitter.creatorId are accepted by the Metadata type but are not currently rendered by the <SEO /> component. They are available in the resolved metadata object if you need to render them manually.