OG & Metadata Toolkit
One helper that gives every page consistent SEO and social cards.
Builders & toolkitsLiveNext.jsnext/og
3.7kcards
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
Want a system like this built for you?Work with me →