← The Store

OG & Metadata Toolkit

One helper that gives every page consistent SEO and social cards.

Builders & toolkitsLiveNext.jsnext/og
4.1kcards

What it is

A centralized metadata builder plus dynamic OpenGraph image generation. Every page calls one helper for canonical URLs, OG tags, Twitter cards and Bauhaus-styled social images — consistent SEO with no boilerplate.

Take it with you

The real, committed source behind this system — copy it or download the file. Plus a portable spec of everything on this page.

metadata.jsjavascript304 lines

The central metadata builder — canonical URLs, OpenGraph/Twitter tags and dynamic OG images from one buildMetadata() call.

// ---------------------------------------------------------------------------
// buildMetadata — centralized metadata for every page on jakelawrence.xyz
// buildEnrichedMetadata — enrichment-aware wrapper around buildMetadata
// getEnrichedJsonLd — enrichment-aware JSON-LD helper
// ---------------------------------------------------------------------------
//
// Usage (any page.jsx):
//
//   import { buildMetadata } from '@/lib/metadata'
//
//   export const metadata = buildMetadata({
//     title: 'Stable Match',
//     subtitle: 'Gale-Shapley Algorithm',
//     description: 'Match partners and test for stability...',
//     path: '/games/stable-match',
//   })
//
// The helper handles: title (with layout template awareness), OG tags,
// Twitter Card, canonical URL, and sensible defaults for everything else.
// ---------------------------------------------------------------------------

import { getApprovedMetadata } from '@/lib/enrichments'
import { blogOgImagePath } from '@/lib/og-url'
import { BASE_URL, SITE_NAME, DEFAULT_OG_IMAGE } from '@/lib/site/constants'
import { OG_LOCALE, DEFAULT_LOCALE } from '@/lib/i18n/locales.mjs'

/**
 * Infer a category from the URL path for OG image styling.
 */
function inferCategory(path) {
  if (path.startsWith('/research')) return 'research'
  if (path.startsWith('/novels') || path.startsWith('/the-beautiful-unfinished')) return 'novels'
  if (path.startsWith('/compass/ethics')) return 'ethics'
  if (path.startsWith('/compass/values')) return 'values'
  if (path.startsWith('/compass')) return 'compass'
  if (path.startsWith('/utilities')) return 'utilities'
  if (path.startsWith('/hire')) return 'hire'
  if (path.startsWith('/games')) return 'games'
  const gameRoutes = [
    '/dice-or-die', '/cube-dojo', '/chessguessr', '/rating-game',
    '/machine-mirror', '/word-machine', '/case-cooking',
    '/airway', '/prompt-mirror', '/spa-menu',
    '/subgenre-survival', '/population-game',
  ]
  if (gameRoutes.some(r => path.startsWith(r))) return 'games'
  // Publishable multi-tenant boards — branded OG per add-on. Use trailing-slash
  // prefixes so the board slug routes match (and /t/ never catches /trip|/tools).
  if (path.startsWith('/w/')) return 'weather'
  if (path.startsWith('/cd/')) return 'countdowns'
  if (path.startsWith('/trip/')) return 'trips'
  if (path.startsWith('/t/')) return 'todos'
  return ''
}

/**
 * Build consistent metadata for any page.
 *
 * Options:
 *   title        — Page title (layout template appends " — Jake Lawrence")
 *   subtitle     — Optional subtitle: "Title — Subtitle" in browser tab
 *   description  — Meta description (keep under 155 chars)
 *   path         — Page path starting with /
 *   ogImage      — OG image path (defaults to dynamic Bauhaus OG via /api/og)
 *   ogImageAlt   — Alt text for the OG image (defaults to title)
 *   type         — 'website' (default) or 'article'
 *   absoluteTitle — If true, bypasses layout's " — Jake Lawrence" suffix
 *   locale       — Content language ('en' default, 'uk' for /uk pages). Sets
 *                  the OpenGraph locale tag.
 *   hreflang     — Optional map of locale → canonical path (e.g.
 *                  { en: '/research', uk: '/uk/research' }). When provided,
 *                  emits alternates.languages (+ x-default → the en entry) so
 *                  search engines pair the localized twins. Use hreflangMap()
 *                  from @/lib/i18n to build it.
 */
export function buildMetadata({
  title,
  subtitle,
  description,
  path,
  ogImage,
  ogImageAlt,
  type = 'website',
  absoluteTitle = false,
  keywords,
  locale = DEFAULT_LOCALE,
  hreflang,
}) {
  const url = `${BASE_URL}${path}`

  // hreflang → absolute-URL languages map, with x-default pointing at English.
  const languages = hreflang
    ? Object.fromEntries(
        Object.entries({ ...hreflang, 'x-default': hreflang['x-default'] || hreflang.en })
          .filter(([, p]) => p)
          .map(([k, p]) => [k, p.startsWith('http') ? p : `${BASE_URL}${p}`])
      )
    : undefined

  // Dynamic OG image generation when no explicit ogImage provided
  const category = inferCategory(path)
  const dynamicOg = `/api/og?${new URLSearchParams({
    title,
    ...(description && { subtitle: description }),
    ...(category && { category }),
  })}`
  const image = ogImage || dynamicOg
  const imageUrl = image.startsWith('http') ? image : `${BASE_URL}${image}`

  // Browser tab title: "Title — Subtitle" (layout template appends " — Jake Lawrence")
  const tabTitle = subtitle ? `${title} \u2014 ${subtitle}` : title

  // OG/Twitter title: standalone, includes site name since social cards
  // don't go through the Next.js layout title template
  const socialTitle = subtitle
    ? `${title} \u2014 ${subtitle} | ${SITE_NAME}`
    : `${title} | ${SITE_NAME}`

  return {
    title: absoluteTitle ? { absolute: tabTitle } : tabTitle,
    description,
    ...(keywords?.length && { keywords }),
    alternates: {
      canonical: url,
      ...(languages && { languages }),
    },
    openGraph: {
      title: socialTitle,
      description,
      url,
      siteName: SITE_NAME,
      locale: OG_LOCALE[locale] || OG_LOCALE[DEFAULT_LOCALE],
      type,
      images: [
        {
          url: imageUrl,
          width: 1200,
          height: 630,
          alt: ogImageAlt || tabTitle,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: socialTitle,
      description,
      images: [imageUrl],
    },
  }
}

/**
 * Build JSON-LD structured data for a WebApplication (game) page.
 * Returns a JSON string to embed in a <script type="application/ld+json"> tag.
 */
export function buildGameJsonLd({ name, description, path }) {
  return JSON.stringify({
    '@context': 'https://schema.org',
    '@type': 'WebApplication',
    name,
    description,
    url: `${BASE_URL}${path}`,
    applicationCategory: 'EducationalApplication',
    operatingSystem: 'Web Browser',
    author: {
      '@type': 'Person',
      name: SITE_NAME,
      url: BASE_URL,
    },
    offers: {
      '@type': 'Offer',
      price: '0',
      priceCurrency: 'USD',
    },
  })
}

/**
 * Build JSON-LD structured data for an Article page.
 */
export function buildArticleJsonLd({ headline, description, path, wordCount, genre }) {
  return JSON.stringify({
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline,
    description,
    url: `${BASE_URL}${path}`,
    wordCount,
    ...(genre && { genre }),
    author: {
      '@type': 'Person',
      name: SITE_NAME,
      url: BASE_URL,
    },
    publisher: {
      '@type': 'Person',
      name: SITE_NAME,
    },
  })
}

/**
 * Build BreadcrumbList JSON-LD for any page.
 */
export function buildBreadcrumbJsonLd(path) {
  const segments = path.split('/').filter(Boolean)
  const items = [
    { '@type': 'ListItem', position: 1, name: 'Home', item: BASE_URL },
  ]

  let currentPath = BASE_URL
  segments.forEach((segment, i) => {
    currentPath += `/${segment}`
    items.push({
      '@type': 'ListItem',
      position: i + 2,
      name: segment.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
      item: currentPath,
    })
  })

  return JSON.stringify({
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items,
  })
}

// ---------------------------------------------------------------------------
// Enrichment-aware helpers
// ---------------------------------------------------------------------------

/**
 * Build metadata using approved enrichment data when available, falling back
 * to the provided hardcoded values.
 *
 * Usage:
 *   export const metadata = buildEnrichedMetadata('game-stable-match', {
 *     title: 'Stable Match — Gale-Shapley Algorithm',
 *     description: 'Match partners and test for stability...',
 *     path: '/games/stable-match',
 *   })
 */
export async function buildEnrichedMetadata(assetId, fallback) {
  const enriched = await getApprovedMetadata(assetId)
  if (!enriched) return buildMetadata(fallback)
  return buildMetadata({
    ...fallback,
    title: enriched.title || fallback.title,
    description: enriched.description || fallback.description,
    keywords: enriched.tags,
  })
}

/**
 * Return enriched JSON-LD string if available, otherwise the fallback string.
 *
 * Usage:
 *   <script type="application/ld+json" dangerouslySetInnerHTML={{
 *     __html: getEnrichedJsonLd('game-stable-match', buildGameJsonLd({...}))
 *   }} />
 */
export async function getEnrichedJsonLd(assetId, fallbackJsonLd) {
  const enriched = await getApprovedMetadata(assetId)
  if (enriched?.jsonLd) return JSON.stringify(enriched.jsonLd)
  return fallbackJsonLd
}

// ---------------------------------------------------------------------------
// Blog post metadata
// ---------------------------------------------------------------------------

/**
 * Build metadata for a blog post page with Discover-optimized robots directives.
 *
 * Usage:
 *   export async function generateMetadata({ params }) {
 *     const post = getBlogPostBySlug(params.slug)
 *     return buildBlogPostMetadata(post)
 *   }
 */
export function buildBlogPostMetadata(post) {
  if (!post) return buildMetadata({ title: 'Post Not Found', description: '', path: '/blog' })

  const heroImage = blogOgImagePath(post)

  return {
    ...buildMetadata({
      title: post.title,
      description: post.description,
      path: `/blog/${post.slug}`,
      ogImage: heroImage,
      ogImageAlt: post.heroAlt || post.title,
      type: 'article',
      keywords: post.tags,
    }),
    robots: {
      index: true,
      follow: true,
      'max-image-preview': 'large',
      'max-snippet': -1,
      'max-video-preview': -1,
    },
  }
}

Where it lives

  • src/lib/metadata.js
  • src/lib/structured-data.jsx
  • src/app/api/og/route.jsx

See it in action

Architecture map

FAQ

What does the toolkit cover?

Canonical URLs, OpenGraph and Twitter tags, structured-data JSON-LD, and dynamically generated Bauhaus OG images — all from a single buildMetadata helper.

Part of these stacks

Related systems

Bauhaus Token SystemThe site-wide design language: CSS variables + JS tokens, dark-aware.Structured Data ComponentsReusable JSON-LD schema builders for rich search results.Email Identity RegistryPer-surface "From" lines so no brand leaks onto another.

Explore the full catalog →

Want a system like this built for you?Work with me →