Table of contents
- Overview
- Features — All supported fields (exhaustive)
- Installation (bun only)
- Quick start (App Router)
- API summary
- Examples (App Router: layout.tsx)
- Simple example (static metadata)
- Medium example (full features demo, parent merge, dynamic params)
- Validation
- Recommendations
- Contributing and license
@0xgotchi/seo converts a single SEO configuration object (SEOConfig) into metadata objects compatible with the Next.js App Router Metadata API (NextJSMetadata). It provides utilities to:
- generate static metadata for App Router (
generateStaticNextMetadata) - generate metadata that can merge with a parent metadata (
generateNextMetadata) - generate metadata with param placeholders replaced (
generateDynamicNextMetadata) - convert an SEOConfig into a metadata object (
metadata) - validate an SEOConfig and return errors/warnings (
validateSEOConfig) - normalize favicons and map them into Next.js icons shape
- propagate many advanced fields (serviceWorker, webAssembly, streaming, rspack, bundleAnalysis, etc.)
This README contains two ready-to-copy layout.tsx examples. The medium example exposes an ALL_FEATURES object and renders it so the page shows absolutely every feature the library supports.
When provided in the input SEOConfig, the library will propagate the following fields into the resulting NextJSMetadata. The resulting metadata filters out undefined values; only provided fields will be present.
Top-level fields and groups supported:
title,defaultTitle,titleTemplatedescriptionkeywordssiteUrlmetadataBaseviewportformatDetectionreferrercolorSchemethemeColormanifest/pwa.manifesttwitter(card, title, description, creator, site, images, player, player_width, player_height)openGraph(OpenGraphData): title, description, url, siteName, type, images, videos, audios, documents, event, product, article, profile, musicPlaylist, book, latitude, longitude, locale, locale_alternate, fb_pages, fb_app_id, news_keywords, publication_date, determiner, accessibilityLabel, accessibilityHint, copyright, license, rating, age_group, estimated_reading_timealternates: canonical, languages, favicons, feeds, appLinks, media, formats (the library toleratesalternates.faviconor a top-levelfaviconfor backwards compatibility)hreflang(array of{ lang, href })breadcrumbsstructuredDataschemaRecipe,schemaFAQ,schemaProduct,schemaEvent,schemaReviewcustomMeta(array of{ name?, property?, content })socialProfilespreloadAssetsverificationauthorspublishercategory,classification,creatorruntime(edge|nodejs)streaming(enabled,priority)experimental(ppr,serverActions,turbopack)loading(skeleton,suspense)error(boundary,fallback)bundleAnalysis(enabled,chunks)rspack(optimization)webAssembly(enabled,modules)serviceWorker(enabled,cacheStrategy)other(arbitrary record)- Any extra unknown top-level keys are preserved in the output
Notes:
- The
metadata(options)helper propagates all the fields listed below, including:themeColor,alternates,breadcrumbs,structuredData,customMeta,pwa, andsocialProfiles. generateNextMetadataandgenerateStaticNextMetadataapply sensible fallbacks for title, description, viewport and colorScheme.- Favicons are normalized: the code checks
alternates.favicons, thenalternates.favicon, then a top-levelfaviconobject. - If a
parentAsyncMetadatais provided togenerateNextMetadata, the library mergesparent.openGraph.imageswith the child'sopenGraph.images.
Install with bun:
bun add @0xgotchi/seoUse one of these patterns in the Next.js App Router:
- Static metadata exported from a layout/page
- Use
generateStaticNextMetadataand export the result as:Or, as a shortcut, you can export directly via theexport const metadata = generateStaticNextMetadata(mySEOConfig)
metadatahelper:import { metadata as buildMetadata } from "@0xgotchi/seo"; export const metadata = buildMetadata(mySEOConfig);
- Dynamic metadata that merges with parent metadata
- Implement
export async function generateMetadata(parent)in your layout/page and call:const metadata = await generateNextMetadata(mySEOConfig, parent)
- Dynamic metadata with parameter substitution
- Use
generateDynamicNextMetadata(params, options)to replace placeholders like{slug}intitleordescription.
Which to use:
- Use
generateStaticNextMetadatafor static/SSG content. - Use
generateNextMetadatainside agenerateMetadata(parent)implementation to merge metadata with a parent layout. - Use
generateDynamicNextMetadatato inject path params into templates.
Exports:
generateNextMetadata(options: SEOConfig, parent?: AsyncMetadata): Promise<NextJSMetadata>generateStaticNextMetadata(options: SEOConfig): NextJSMetadatagenerateDynamicNextMetadata(params: { [k: string]: string | string[] }, options: SEOConfig): Promise<NextJSMetadata>metadata(options: SEOConfig): NextJSMetadatagenerateLayoutNextMetadata(alias tometadata)validateSEOConfig(options: SEOConfig): Array<{ type: "error"|"warning", field: string, message: string }>
Types:
SEOConfig— input config shapeNextJSMetadata— Next.js Metadata API compatible shapeAsyncMetadata— Promise resolving to partial metadata (used asparent)
See index.d.ts in the package for exact TypeScript declarations.
Below are two complete examples formatted as layout.tsx files. They follow the minimal RootLayout structure produced by npx create-next-app but include the @0xgotchi/seo usage. Copy the file contents into app/layout.tsx or separate layout files as indicated by the file path hint.
File path hint: app/simple-layout.tsx
import React from "react";
import { generateStaticNextMetadata, validateSEOConfig, type SEOConfig, type NextJSMetadata } from "@0xgotchi/seo";
const simpleSEOConfig: SEOConfig = {
title: "Simple Page",
description: "A very simple page description for SEO",
alternates: {
canonical: "https://example.com/simple",
favicons: [{ rel: "icon", href: "/favicon-simple.png", sizes: "32x32", type: "image/png" }],
},
openGraph: {
title: "Simple OG Title",
description: "OpenGraph description for simple page",
images: [{ url: "https://example.com/simple-og.png", width: 1200, height: 630 }],
},
// Newly highlighted fields (also propagated by metadata())
themeColor: "#ffffff",
breadcrumbs: [
{ name: "Home", url: "/" },
{ name: "Simple" },
],
structuredData: [
{ "@type": "WebPage", name: "Simple Page", url: "https://example.com/simple" },
],
customMeta: [
{ name: "color-scheme", content: "light dark" },
],
pwa: {
manifest: "/manifest.json",
themeColor: "#ffffff",
},
socialProfiles: [
{ network: "twitter", url: "https://twitter.com/example" },
],
};
export const metadata: NextJSMetadata = generateStaticNextMetadata(simpleSEOConfig);
export default function RootLayout({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV !== "production") {
const issues = validateSEOConfig(simpleSEOConfig);
if (issues.length > 0) {
// eslint-disable-next-line no-console
console.warn("SEO validation issues (simple):", issues);
}
}
return (
<html lang="en">
<head />
<body>
<div style={{ padding: 24 }}>
<header>
<h1>Simple Layout</h1>
<p>Static metadata exported using generateStaticNextMetadata.</p>
</header>
<main>{children}</main>
</div>
</body>
</html>
);
}File path hint: app/medium-layout.tsx
This example demonstrates:
ALL_FEATURES— a comprehensive SEOConfig object showing essentially all supported fieldsgenerateMetadata(parent?)— merges withparentmetadata viagenerateNextMetadatagenerateDynamicNextMetadata— shows how to replace{slug}and other placeholders with params- The layout renders the
ALL_FEATURESJSON so the page displays absolutely every resource offered
import React from "react";
import {
generateNextMetadata,
generateDynamicNextMetadata,
validateSEOConfig,
type SEOConfig,
type NextJSMetadata,
type AsyncMetadata,
} from "@0xgotchi/seo";
/**
* ALL_FEATURES: comprehensive example configuration demonstrating virtually every supported field.
* Replace sample URLs / values with your real values when using in production.
*/
const ALL_FEATURES: SEOConfig = {
title: "Full Features Demo",
defaultTitle: "Default Site Title",
titleTemplate: "%s | Example Site",
description: "A comprehensive SEO configuration demonstrating all supported fields.",
keywords: ["seo", "metadata", "nextjs", "example"],
siteUrl: "https://example.com",
metadataBase: "https://example.com",
viewport: "width=device-width, initial-scale=1",
formatDetection: { telephone: false },
referrer: "strict-origin-when-cross-origin",
colorScheme: "light dark",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#000000" },
],
manifest: "/site.webmanifest",
pwa: {
manifest: "/site.webmanifest",
themeColor: "#ffffff",
appleStatusBarStyle: "black-translucent",
appleMobileWebAppCapable: true,
},
twitter: {
card: "summary_large_image",
title: "Twitter Title",
description: "Twitter description",
creator: "@example",
site: "@example",
images: ["https://example.com/twitter-image.jpg"],
player: "https://example.com/player",
player_width: 1280,
player_height: 720,
},
openGraph: {
title: "OG Title",
description: "OG Description",
url: "https://example.com/page",
siteName: "Example Site",
type: "website",
images: [
{ url: "https://example.com/og1.png", width: 1200, height: 630, alt: "OG 1" },
{ url: "https://example.com/og2.png", width: 800, height: 600, alt: "OG 2" },
],
videos: [{ url: "https://example.com/video.mp4", width: 1280, height: 720, type: "video/mp4", alt: "Video demo" }],
audios: [{ url: "https://example.com/audio.mp3", type: "audio/mpeg", alt: "Audio demo" }],
documents: [{ url: "https://example.com/spec.pdf", type: "application/pdf", title: "Spec PDF" }],
event: { name: "Demo Launch", start_time: "2026-01-01T10:00:00Z", end_time: "2026-01-01T12:00:00Z", location: "Online", url: "https://example.com/event" },
product: { name: "Example Product", price: "19.99", currency: "USD", brand: "Example", availability: "in stock", url: "https://example.com/product" },
latitude: 37.7749,
longitude: -122.4194,
article: { author: "Jane Doe", published_time: "2025-10-01T12:00:00Z", modified_time: "2025-10-02T12:00:00Z", section: "News", tag: ["release", "example"] },
profile: { first_name: "Jane", last_name: "Doe", username: "janedoe", gender: "female" },
musicPlaylist: { creator: "DJ Example", song: [{ url: "https://example.com/song.mp3", title: "Hit Single" }] },
book: { author: "Author Example", isbn: "978-3-16-148410-0", release_date: "2025-09-01" },
locale: "en-US",
locale_alternate: ["pt-BR"],
fb_pages: ["1234567890"],
fb_app_id: "9876543210",
news_keywords: ["news", "updates"],
publication_date: "2025-10-01T00:00:00Z",
determiner: "the",
accessibilityLabel: "Main page",
accessibilityHint: "Opens main content",
copyright: "© 2025 Example",
license: "MIT",
rating: "5/5",
age_group: "all",
estimated_reading_time: "5 min",
},
alternates: {
canonical: "https://example.com/full",
languages: { "en-US": "https://example.com/en", "pt-BR": "https://example.com/pt" },
favicons: [
{ rel: "icon", href: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
feeds: [{ type: "application/rss+xml", href: "/rss.xml" }],
appLinks: [{ platform: "android", url: "android-app://com.example" }],
media: [{ media: "only screen and (max-width: 600px)", href: "/mobile" }],
formats: [{ type: "application/pdf", href: "/document.pdf" }],
},
hreflang: [{ lang: "en-US", href: "https://example.com/en" }],
breadcrumbs: [{ name: "Home", url: "/" }, { name: "Docs", url: "/docs" }],
structuredData: [{ "@type": "WebSite", name: "Example", url: "https://example.com" }],
schemaRecipe: { "@type": "Recipe", name: "Sample Recipe" },
schemaFAQ: { "@type": "FAQPage" },
schemaProduct: { "@type": "Product", name: "Example Product" },
schemaEvent: { "@type": "Event", name: "Launch Event" },
schemaReview: { "@type": "Review", reviewBody: "Excellent!" },
customMeta: [{ name: "color-scheme", content: "light dark" }],
socialProfiles: [{ network: "twitter", url: "https://twitter.com/example" }],
preloadAssets: [{ href: "/fonts/example.woff2", as: "font", type: "font/woff2", crossorigin: "anonymous" }],
verification: { google: "google-verification-code" },
authors: [{ name: "Jane Doe", url: "https://example.com/authors/jane" }],
publisher: "Example Publisher",
category: "Documentation",
classification: "Guides",
creator: "Example Team",
runtime: "edge",
streaming: { enabled: true, priority: "high" },
experimental: { ppr: false, serverActions: true, turbopack: false },
loading: { skeleton: true, suspense: false },
error: { boundary: true, fallback: "/error" },
bundleAnalysis: { enabled: false, chunks: ["main", "vendor"] },
rspack: { optimization: { minimize: false } },
webAssembly: { enabled: false, modules: [] },
serviceWorker: { enabled: false, cacheStrategy: "networkFirst" },
other: { customKey: "customValue" },
};
export async function generateMetadata(parent?: AsyncMetadata) {
const base: SEOConfig = {
title: "Medium Example Page",
description: "This page demonstrates combining parent metadata and showing everything.",
alternates: { canonical: "https://example.com/medium" },
openGraph: { images: [{ url: "https://example.com/medium-og.png", width: 1200, height: 630 }] },
};
const combined: SEOConfig = {
...ALL_FEATURES,
...base,
alternates: { ...(ALL_FEATURES.alternates || {}), ...(base.alternates || {}) },
openGraph: { ...(ALL_FEATURES.openGraph || {}), ...(base.openGraph || {}) },
};
return await generateNextMetadata(combined, parent);
}
/**
* Example: replace placeholders with path params
*
* Params example: { slug: "post-1", author: "alice" }
*/
export async function generateMetadataWithParams(params: { [k: string]: string | string[] }) {
const conf: SEOConfig = {
title: "Article: {slug}",
description: "Post by {author}",
alternates: { canonical: "https://example.com/articles/{slug}" },
};
return await generateDynamicNextMetadata(params, conf);
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV !== "production") {
const issues = validateSEOConfig(ALL_FEATURES);
if (issues.length > 0) {
// eslint-disable-next-line no-console
console.warn("SEO validation issues (full features demo):", issues);
}
}
return (
<html lang="en">
<head />
<body>
<div style={{ padding: 24 }}>
<header>
<h1>Medium Layout — Full Features Demo</h1>
<p>This layout renders the ALL_FEATURES object, showing every supported field.</p>
</header>
<section style={{ marginTop: 20 }}>
<h2>All supported features (example values)</h2>
<pre style={{ background: "#f5f5f5", padding: 12, whiteSpace: "pre-wrap" }}>
{JSON.stringify(ALL_FEATURES, null, 2)}
</pre>
</section>
<main style={{ marginTop: 20 }}>{children}</main>
</div>
</body>
</html>
);
}Use validateSEOConfig during development or CI to surface missing or recommended fields:
import { validateSEOConfig } from "@0xgotchi/seo";
const issues = validateSEOConfig({ title: "Hi" }); // will report missing description, and warnings for other recommended fields
console.log(issues);The validator returns an array of objects, each with:
type:"error"|"warning"field: string path to the offending field (e.g.,openGraph.images)message: human-friendly explanation
- Provide at minimum
title,description,alternates.canonical, and at least oneopenGraph.imagesfor best social sharing and search engine previews. - Use
generateStaticNextMetadatafor static pages and SSG. - Use
generateNextMetadatainsidegenerateMetadata(parent)to merge with parent layout metadata (e.g., to extend parent OpenGraph images). - Use
generateDynamicNextMetadatato inject route params into title/description templates.
Contributions welcome. Open issues and PRs. Add tests for edge cases (OpenGraph image URL types, parent merging, favicon normalization). Include a LICENSE file (for example MIT) in the repository root.
End of README