In the App Router you don't hand-write <meta> tags. You export a typed metadata object or a generateMetadata function from a layout or page file, and Next.js builds the entire <head> for you — title, description, canonical, Open Graph, Twitter Cards, the lot. One source of truth, inherited down the route tree, with no way to accidentally inject a second <title> from a parent layout fighting your page.
That's the short answer. The rest of this is the working code and the two or three things that'll trip you up if you don't know them going in.
If you want the underlying theory first — what these tags actually do, which ones move rankings, and which are cargo cult — start with meta tags for SEO. This post is the implementation side.
Static metadata: the base case
For most pages — homepage, about, static landing pages — a plain exported metadata object is all you need. This goes in a Server Component: either layout.tsx for segment-wide defaults or page.tsx for page-specific values.
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About — Booplex',
description: 'SEO strategist, AI tamer, 10+ years finding opportunities before they have names.',
alternates: {
canonical: 'https://booplex.com/about',
},
openGraph: {
title: 'About — Booplex',
description: 'SEO strategist, AI tamer, 10+ years finding opportunities before they have names.',
url: 'https://booplex.com/about',
siteName: 'Booplex',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'About — Booplex',
description: 'SEO strategist, AI tamer, 10+ years finding opportunities before they have names.',
},
}
export default function AboutPage() {
return <main>...</main>
}
Two things to notice. First: metadata is a named export, not a default export, and it has to be from a Server Component — you can't put it in a client component. Second: canonical lives under alternates.canonical, not some top-level field. I've seen people hunt for that one.
Next.js merges metadata down the route tree. A metadata export in app/layout.tsx sets your site-wide defaults; a metadata export in app/about/page.tsx overrides specific fields while inheriting the rest. So put your siteName, default openGraph.type, and anything else that's universal in the root layout, and let individual pages override what they need.
Dynamic metadata with generateMetadata
Blog posts, project pages, anything fetched from a database — static objects don't cut it there. generateMetadata is an async function Next.js calls at request time (or build time for statically generated routes) to produce the metadata for that specific URL.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPostBySlug } from '@/lib/blog'
import { notFound } from 'next/navigation'
// Props shape Next.js passes in
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) return {}
return {
title: post.metaTitle ?? post.title,
description: post.metaDescription ?? post.excerpt,
alternates: {
canonical: `https://booplex.com/blog/${post.slug}`,
},
openGraph: {
title: post.metaTitle ?? post.title,
description: post.metaDescription ?? post.excerpt,
url: `https://booplex.com/blog/${post.slug}`,
type: 'article',
publishedTime: post.publishedAt?.toISOString(),
images: post.featuredImage
? [{ url: post.featuredImage, width: 1200, height: 630, alt: post.title }]
: [],
},
twitter: {
card: 'summary_large_image',
title: post.metaTitle ?? post.title,
description: post.metaDescription ?? post.excerpt,
},
}
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
return <article>...</article>
}
generateMetadata runs in parallel with the page component — Next.js doesn't wait for one before starting the other. They share the same request/render lifecycle, so if you're fetching the same data in both, React's request deduplication (via fetch or React cache) means you won't hit the database twice.
The parent parameter is also available if you need to extend — not replace — ancestor metadata:
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const resolvedParent = await parent
const post = await getPostBySlug((await params).slug)
return {
title: post?.metaTitle ?? post?.title,
openGraph: {
...resolvedParent.openGraph, // inherit parent OG fields
title: post?.metaTitle ?? post?.title,
type: 'article',
},
}
}
Useful when the root layout sets siteName or a default image and you want pages to inherit those without re-declaring them.
The openGraph and twitter fields
Both live inside the Metadata object and map closely to what you'd write by hand, just typed. The fields worth knowing:
openGraph: {
title: 'Post title',
description: 'Post description — aim for 120–160 chars',
url: 'https://booplex.com/blog/slug',
siteName: 'Booplex',
type: 'article', // 'website' for non-articles
publishedTime: '2026-07-02T09:00:00Z', // article only
modifiedTime: '2026-07-05T12:00:00Z', // article only
authors: ['https://booplex.com/about'], // article only, should be a URL
images: [
{
url: 'https://booplex.com/social/post-og.jpg',
width: 1200,
height: 630,
alt: 'Post title',
},
],
},
twitter: {
card: 'summary_large_image', // or 'summary' for small image
site: '@booplex', // your Twitter handle
creator: '@booplex',
title: 'Post title',
description: 'Post description',
images: ['https://booplex.com/social/post-og.jpg'],
},
One thing X/Twitter does that most people don't bother with: if you omit the twitter:* tags entirely, Twitter falls back to your Open Graph tags. So for most cases you only need twitter.card and maybe twitter.site — the rest can inherit from openGraph. The full breakdown of when to use both vs. just OG is in Open Graph tags explained.
The og:image URL needs to be absolute. Every time. Relative paths don't work for social crawlers — they don't run on your domain, they fetch the HTML cold and have no base URL to resolve against. If you get that wrong, you'll spend twenty minutes staring at a blank preview card wondering what's broken.
Which brings us to metadataBase.
metadataBase: why it exists and why you need it
metadataBase is a URL you set once, usually in the root layout, and Next.js uses it to resolve any relative url, canonical, or images values inside metadata objects across the whole site.
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://booplex.com'),
title: {
default: 'Booplex',
template: '%s — Booplex', // pages set the prefix, layout appends the suffix
},
openGraph: {
siteName: 'Booplex',
type: 'website',
},
}
With metadataBase set, you can write /social/post-og.jpg in a child page and Next.js resolves it to https://booplex.com/social/post-og.jpg in the rendered HTML. Without it, Next.js will try to warn you in development — and in production you'll have relative URLs in your og:image that crawlers silently ignore.
This is also the thing that prevents the localhost canonical disaster. Without a proper metadataBase and without using a centralised URL helper (I use getPublicSiteUrl() from @/lib/utils for this), it's easy to end up with canonical tags pointing at http://localhost:3000 in production — which tells Google that your real page is a duplicate of a local dev URL that doesn't exist on the internet. I wrote the whole story of how that happened and how I fixed it: how I fixed canonical URLs pointing to localhost in Next.js.
Short version: set metadataBase explicitly, and for anything that needs the production URL dynamically, pull it from one authoritative source — not process.env.SITE_URL || 'http://localhost:3000' inlined in ten different files.
Dynamic OG images with next/og
Static OG images work fine for pages that don't change much. For blog posts, projects, or anything where each page should have a distinct, generated image, next/og lets you create an ImageResponse from JSX — rendered via Satori to a PNG at request time (or cached after the first request in production).
Create a route at app/og/route.tsx (or co-locate it as app/blog/[slug]/opengraph-image.tsx for automatic wiring):
// app/og/route.tsx
import { ImageResponse } from 'next/og'
import type { NextRequest } from 'next/server'
// Tell Next.js this is an Edge route so it deploys fast globally
export const runtime = 'edge'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') ?? 'Booplex'
const description = searchParams.get('description') ?? ''
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
width: '100%',
height: '100%',
padding: '60px',
background: '#0a0a0a',
fontFamily: 'sans-serif',
}}
>
<div style={{ fontSize: 56, fontWeight: 700, color: '#ffffff', marginBottom: 16 }}>
{title}
</div>
<div style={{ fontSize: 28, color: '#a3a3a3' }}>
{description}
</div>
<div style={{ marginTop: 40, fontSize: 22, color: '#6b7280' }}>
booplex.com
</div>
</div>
),
{
width: 1200,
height: 630,
}
)
}
Then in your blog post metadata, point the image at this route:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) return {}
const ogImage = `/og?title=${encodeURIComponent(post.metaTitle ?? post.title)}&description=${encodeURIComponent(post.excerpt ?? '')}`
return {
openGraph: {
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: {
images: [ogImage],
},
}
}
metadataBase resolves the relative /og?... path to an absolute URL in the rendered HTML. Which is why setting it in the root layout isn't optional — it's load-bearing for this pattern.
The next/og constraint to know: only a subset of CSS is supported (Satori's layout engine), and custom fonts need to be loaded explicitly as ArrayBuffer and passed to ImageResponse. The Next.js docs on Metadata and OG images have the full font-loading example if you need it.
Why this kills the duplicate <title> bug
The classic duplicate title tag problem: a CMS or framework injects a default <title> in the root template, your page outputs its own, and the crawler sees two. Google picks one — often the wrong one. I went through the whole anatomy of this in duplicate title tags — how they happen and how to fix them.
The App Router's metadata system makes this structurally impossible because there's exactly one pipeline. You export metadata or generateMetadata from a Server Component, and Next.js renders the <head> once. There's no document.write, no template string concatenation, no two systems fighting over the same tag. If a parent layout exports metadata and a page exports metadata, Next.js merges them deterministically — the page wins on fields they both set.
The title.template pattern in the root layout is the idiomatic way to handle this:
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: 'Booplex', // used when no child sets a title
template: '%s — Booplex', // child sets 'About', rendered as 'About — Booplex'
},
}
Every page sets its own title prefix. The brand suffix gets appended automatically. One rule, applied consistently, no duplicates. This also interacts with rendering mode — if you've been wondering whether SSG vs SSR changes how metadata works, the short answer is it doesn't: metadata runs at build time for static routes and at request time for dynamic ones.
The rendered HTML is the same either way. Longer answer: ISR, SSG, SSR — which one actually helps SEO.
Checklist before you ship
The title tags and meta descriptions post covers the limits in depth, but for a quick sanity check on the metadata side:
metadataBaseset in root layout with the production URL — not a localhost fallbacktitle.templateconfigured so pages don't have to repeat the brand name- Every public page has a
generateMetadataormetadataexport (no orphaned routes) og:imageURLs resolve to absolute paths (check the rendered HTML source, not the JSX)alternates.canonicalset on every page with the correct production URLtwitter.cardissummary_large_imageif you have a proper OG image- If something looks right in code but the preview is still broken, the platform cached the old version — here's how to fix that
The official reference for all the available fields is the Next.js generateMetadata API docs — worth bookmarking, since they keep the type signatures up to date.
Sources (retrieved 2026-06-03):
- Next.js —
generateMetadatafunction API reference: nextjs.org/docs/app/api-reference/functions/generate-metadata - Next.js — Metadata and OG images getting started guide: nextjs.org/docs/app/getting-started/metadata-and-og-images
