This page covers two related systems, page metadata (titles, descriptions, social cards) and structured data (JSON-LD). Both are built per page and both read from a shared source of truth.
Every static page’s title and description lives in one file, config/metadata.ts. This is the first place to edit when changing how a page is titled or described.
export const metaData = {
pricing: {
title: "Pricing",
description: "Choose the plan that works best for you.",
},
about: {
title:
0 / 2,000 characters
Pages meant to stay out of search results (dashboard, settings, admin, and similar account-area pages) carry an explicit robots: { index: false, follow: false } entry in config/metadata.ts. That entry is documentation and a reminder, but it does not apply itself automatically. The actual mechanism is the noIndex: true parameter on buildPageMetadata(), which sets robots: { index: false, follow: false } in the returned Metadata object. You must pass both: the robots block in the config entry as a signal to future editors, and noIndex: true in the buildPageMetadata() call on the page itself.
Forgetting noIndex: true in the buildPageMetadata() call on a private page
leaves that page eligible for indexing, regardless of what the
config/metadata.ts entry says. The robots block in the config is a
convention for editors, not an enforcement mechanism. Always pass noIndex: true explicitly for anything behind a login.
buildPageMetadata() turns a config/metadata.ts entry into a complete Next.js Metadata object, title, description, canonical URL, Open Graph tags, and Twitter card, in one call.
export function buildPageMetadata({
title,
description,
canonical,
image,
noIndex,
section,
absoluteTitle,
}: BuildPageMetadataInput): Metadata {
// builds title, openGraph, and twitter fields from the inputs
}A typical page calls it once and exports the result.
metaData is the full config/metadata.ts export re-nested under siteConfig.seo, so the access path siteConfig.seo.metaData.pricing.title is equivalent to importing metaData from config/metadata.ts directly and reading metaData.pricing.title.
export const metadata = buildPageMetadata({
title: siteConfig.seo.metaData.pricing.title,
description: siteConfig.seo.metaData.pricing.description,
canonical: `${siteConfig.brand.url}/pricing`,
})Two optional parameters control how the title is constructed. section inserts a middle segment, so buildPageMetadata({ title: "Overview", section: "Docs" }) produces Overview | Docs | {Brand}. The docs pages use this. absoluteTitle suppresses the brand suffix entirely and passes the title through as-is, for cases where the full title string is composed elsewhere.
Next.js supports two ways to export metadata from a page, and which one a page uses depends on whether its title and description are known ahead of time or only known once the page is actually requested.
A static page, pricing, about, features, knows its title and description in advance, since they live in config/metadata.ts and never change at runtime. These pages export a plain metadata object, computed once.
export const metadata = buildPageMetadata({ ... })A dynamic page, a blog post, a docs page, does not know its title in advance. The actual title lives in the database or in a content file, and which post or doc is being viewed depends on the URL. These pages export an async generateMetadata() function instead, which Next.js calls with the page’s route parameters and waits for before rendering.
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const page = source.getPage(normalizedSlug)
return buildPageMetadata({ title: page.data.title, ... })
}The rule of thumb, if the title and description are fixed and known today, use export const metadata. If they depend on which specific post, author, or doc page is being loaded, use export async function generateMetadata().
Static pages share one shape, a title, a description, and a canonical URL known ahead of time. Dynamic pages do not, a blog post’s title comes from the database, not from config/metadata.ts. For these, Oolvay ships dedicated builders, build-post-metadata.ts, build-author-metadata.ts, and build-category-metadata.ts, alongside build-page-metadata.ts.
export function buildPostMetadata(post: Post, url: string): Metadata {
const description = post.excerpt || post.logline || ""
const image = `${siteConfig.brand.url}/blog/${post.slug}/opengraph-image`
return {
title: `${post.title} | Blog`,
description,
alternates: { canonical: url },
openGraph: {
type: "article",
url,
title: fullTitle,
description,
images: [{ url: image }],
},
twitter: {
card: "summary_large_image",
title: fullTitle,
description,
images: [image],
},
}
}Posts build their Open Graph and Twitter fields directly rather than going through buildPageMetadata(), since a post’s type is "article" rather than "website" and its image is generated per post rather than shared sitewide. The Post type is not a hand-written interface. It is InferSelectModel<typeof post> derived directly from the Drizzle blog schema, so it stays in sync with the database automatically. Follow this same pattern, a dedicated builder function, for any other dynamic content type you add.
buildDocDescription(), referenced in the example below, is a small helper defined locally inside app/docs/[[...slug]]/page.tsx. It is not an exported utility. If you need the same fallback logic on another page, copy the one-liner directly.
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const page = source.getPage(normalizedSlug)
return buildPageMetadata({
title: page.data.title,
description: buildDocDescription(page.data.title, page.data.description),
canonical,
section: "Docs",
image: `${siteConfig.brand.url}/docs-og/${slugPath}`,
})
}Structured data is JSON-LD, a script tag containing a machine-readable description of what a page is. It does not change how a page looks to a visitor. It changes how a search engine understands the page, which can unlock richer search results like star ratings, pricing, or breadcrumb trails.
Oolvay builds structured data in three layers.
Every single page carries one base JSON-LD graph, rendered once in the root layout’s <head>. This describes the site itself and the organization behind it, regardless of which page is being viewed.
export function JsonLd() {
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": `${siteConfig.brand.url}/#website`,
url: siteConfig.brand.url,
name: siteConfig.brand.name,
description: siteConfig.seo.metaData.home.description,
},
{
"@type": "Organization",
"@id": `${siteConfig.brand.url}/#organization`,
url: siteConfig.brand.url,
name: siteConfig.brand.name,
logo: {
"@type": "ImageObject",
url: `${siteConfig.brand.url}/opengraph-image.png`,
},
},
],
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}This is mounted once, in the root layout, and never per page.
<head>
<JsonLd />
</head>lib/seo/schema/organization.ts and website.ts export the same Organization and WebSite shapes as small, reusable functions. Page-specific JSON-LD builders import these rather than retyping the organization’s name and logo on every page.
export function organizationSchema() {
return {
"@type": "Organization",
"@id": `${siteConfig.brand.url}/#organization`,
name: siteConfig.brand.name,
url: siteConfig.brand.url,
logo: {
"@type": "ImageObject",
url: `${siteConfig.brand.url}/opengraph-image.png`,
},
}
}The @id field is what makes this reusable. Any other JSON-LD node can reference ${siteConfig.brand.url}/#organization instead of repeating the full object, and search engines treat it as the same entity.
Each route that warrants structured data has its own builder in lib/seo/jsonld/, build-about-jsonld.ts, build-pricing-jsonld.ts, build-post-jsonld.ts, build-category-jsonld.ts, build-author-jsonld.ts, build-docs-jsonld.ts, and build-breadcrumb-jsonld.ts. These describe what makes that specific page unique, an Offer for a pricing tier, an Article for a blog post, a BreadcrumbList for navigation depth.
The pricing page builder shows the full pattern, reusing layer two’s fragments and linking page-specific nodes back to them by @id.
export function buildPricingJsonLd() {
return {
"@context": "https://schema.org",
"@graph": [
organizationSchema(),
websiteSchema(),
{
"@type": "SoftwareApplication",
"@id": `${baseUrl}/#software`,
offers: offers.map((offer) => ({ "@id": offer["@id"] })),
},
...offers,
{
"@type": "WebPage",
isPartOf: { "@type": "WebSite", "@id": `${baseUrl}/#website` },
about: {
"@type": "SoftwareApplication",
"@id": `${baseUrl}/#software`,
},
},
],
}
}The page renders this as a second <script> tag, in the page body rather than the root layout, since it only applies to that one route.
const jsonLd = buildPricingJsonLd()
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* page content */}
</>
)A page can carry more than one page-specific JSON-LD script. Docs pages render the breadcrumb graph first, then the docs-page graph. Blog post pages render the post graph first, then the breadcrumb graph. The order does not affect how search engines process the scripts, so each page type follows whatever order reads most naturally for that context.
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(docsJsonLd) }} />Every page that ships with the kit already has its metadata and JSON-LD wired up. The steps below are only for new pages you add yourself.
Decide which schema.org type fits the page, Article, Product, FAQPage, and so on. schema.org lists every available type.
Create a new file in lib/seo/jsonld/, named build-{page}-jsonld.ts, following the existing builders as a template.
Import organizationSchema() and websiteSchema() from lib/seo/schema/ rather than retyping the organization and website objects.
Call your new builder from the page and render its result as a <script type="application/ld+json"> tag in the page body, not the root layout.
Validate the output with Google’s Rich Results Test before shipping.
If you have not seen dangerouslySetInnerHTML before, the name is React’s way of making you pause before using it. This section explains why it is used for JSON-LD, why the Next.js <Script> component is not a substitute, and why there is no security risk in this specific case.
React normally escapes everything it renders. The string <script> becomes <script> in the output, inert text, not a real script tag. That escaping is exactly what you want for user content, and exactly what you do not want for JSON-LD, which must be a real <script> tag in the HTML for search engines to read it. dangerouslySetInnerHTML bypasses the escaping and lets React emit the raw string, which is the only way to produce a valid <script type="application/ld+json"> block.
The Next.js <Script> component is not a substitute either. It is designed for third-party scripts and gives you loading strategy controls (beforeInteractive, afterInteractive, lazyOnload). Any strategy other than beforeInteractive defers or delays execution and may not be present in the raw HTML that crawlers see. Even beforeInteractive is intended for scripts that need to run before the page is interactive, not for inert data blocks. A plain <script> tag in a server component renders directly into the initial HTML with no client-side involvement, which is exactly what JSON-LD needs.
XSS via dangerouslySetInnerHTML requires untrusted input, a string that came from a user, a database, or an external API and was never sanitised. Every value injected into these JSON-LD blocks comes from one of two places: siteConfig constants that are hard-coded at build time, or page data (post.title, page.data.title) that is passed through JSON.stringify() before being set. JSON.stringify() escapes the characters that matter for injection (double quotes, backslashes, angle brackets) so even if a blog post title contained a string like </script><script>alert(1), it would be serialised as \u003c/script\u003e\u003cscript\u003ealert(1) and rendered as inert text inside the JSON string. The attack surface does not exist.
dangerouslySetInnerHTML is dangerous when the string is user-controlled and un-escaped. Here the string is either a compile-time constant or the output of JSON.stringify(), which escapes by design. The name is a prompt to check those two things, and both check out.