Building SEO-Friendly Pages with Next.js
I cover static and dynamic metadata in the App Router, how to generate OG images, and why JSON-LD needs a native script tag rather than next/script.

Most React apps ship a near-empty HTML document. The browser downloads a script bundle, executes it, and then the content appears. That pipeline works fine for users — but search engine crawlers read the raw HTML, and if your <head> is empty and your content arrives later via JavaScript, you’re invisible to them.
Next.js App Router renders components on the server before the response leaves. The HTML that reaches the crawler already contains your content and your metadata. The part that catches developers is that there are three different systems for controlling what goes in <head>, each for a different job. Reaching for the wrong one produces no error but also no <title> tag.
Quick answer: For pages with fixed metadata, export a
metadataobject. For pages whose titles and descriptions depend on fetched data, export an asyncgenerateMetadatafunction. For social share images, drop anopengraph-image.tsxfile alongside the route. For structured data, embed a<script type="application/ld+json">tag directly in JSX — thenext/scriptcomponent is the wrong tool for this job.
Series: Part 4 of 6 in the Next.js series.
On this page
- Why the rendering model matters for crawlers
- Static metadata with the metadata export
- Dynamic metadata with generateMetadata
- Generating Open Graph images
- Structured data — and why next/script is the wrong choice
- Common mistakes and how to spot them
- Stop treating SEO as a finishing step
Why the rendering model matters for crawlers
Understanding the request lifecycle helps here. When a search engine crawler fetches a URL, it receives the raw HTTP response. A pure client-side React app sends something like this as the initial HTML:
<!doctype html>
<html>
<head><title>My App</title></head>
<body><div id="root"></div></body>
</html>
Your actual content isn’t there. It arrives after JavaScript executes. Googlebot does run JavaScript, but it processes pages in a render queue and may delay the crawl by days. Other crawlers skip JavaScript entirely.
Next.js App Router uses React Server Components to render on the server. The HTML in the initial response already includes your headings, paragraphs, and links — and when you add a metadata export, your <title>, <meta name="description">, and Open Graph tags arrive with it. If you want a refresher on components and how React renders them, the React Fundamentals post covers the mental model.
The other thing to know: a statically rendered page (pre-built at deploy time) is the best signal you can send to crawlers. It loads fast, it’s always available from a CDN, and the content is deterministic. Next.js renders a page statically by default when there’s nothing dynamic in the route. Use it when the content doesn’t change per user or per request.
Static metadata with the metadata export
For pages with a fixed title and description — a homepage, a pricing page, an about page — export a Metadata object from layout.tsx or page.tsx:
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About',
description: 'Learn about the team and how we work.',
openGraph: {
title: 'About',
description: 'Learn about the team and how we work.',
type: 'website',
},
}
export default function AboutPage() {
return <main>...</main>
}
Set a title.template in your root layout so every child page inherits the site name automatically:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
template: '%s | MySite',
default: 'MySite',
},
}
When a child page sets title: 'About', Next.js produces <title>About | MySite</title>. The metadataBase field is required for any relative URLs in openGraph.images — omit it and the build errors on any page that sets an OG image path.
Check this before moving on
- The root
layout.tsxexportsmetadataBaseandtitle.template - Every static page exports its own
titleanddescription - View-source on the page shows the
<title>tag in the HTML (not just the DOM in DevTools)
Dynamic metadata with generateMetadata
Blog posts, product pages, and documentation pages have titles and descriptions that come from a database or API. Export an async generateMetadata function instead of a static object:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
},
}
}
export default async function BlogPage({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return <article>...</article>
}
Two constraints apply: generateMetadata is only supported in Server Components, and you cannot export both metadata and generateMetadata from the same route segment — pick one.
When both generateMetadata and the page component need the same data, Next.js automatically deduplicates fetch calls made with the same URL within the same render. For database queries that don’t use fetch, wrap the function in React’s cache to get the same guarantee.
Generating Open Graph images
Social platforms show a 1200×630 preview card when someone shares a URL. Pointing that at a static image is fine for a homepage, but for blog posts you want an image that reflects the actual title.
The file-based approach is the most maintainable option. Create opengraph-image.tsx alongside the route:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = { params: Promise<{ slug: string }> }
export default async function Image({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '72px',
background: '#fafaf9',
color: '#1a1a1a',
}}
>
<h1 style={{ fontSize: 56, lineHeight: 1.2, margin: 0 }}>
{post.title}
</h1>
</div>
)
)
}
ImageResponse supports flexbox and a subset of CSS properties — no CSS Grid, no position: absolute outside a flex container. Keep the layout simple. When Next.js sees this file, it automatically wires the og:image and twitter:image tags without you touching the metadata export.

Each metadata path in the App Router covers a different job — the static export for fixed values, the async function for data-driven pages, and the file convention for generated social images.
Structured data — and why next/script is the wrong choice
Structured data is a JSON block embedded in a <script> tag that tells search engines what type of content a page is — an article, a product, a recipe — and can earn rich results like star ratings or FAQ snippets.
The common instinct is to reach for the next/script component. Don’t. The official Next.js guide on JSON-LD is explicit about this:
The
next/scriptcomponent is optimized for loading and executing JavaScript. Since JSON-LD is structured data, not executable code, a native<script>tag is the right choice here.
next/script adds async loading strategies designed for third-party scripts. For structured data, you want the block rendered synchronously in the initial HTML — not deferred or lazy-loaded. Use a native <script> tag with dangerouslySetInnerHTML:
// app/blog/[slug]/page.tsx
export default async function BlogPage({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.authorName,
url: post.authorUrl,
},
image: post.coverImageUrl,
description: post.excerpt,
}
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
{/* page content */}
</article>
)
}
The .replace(/</g, '\\u003c') step converts any < characters inside string values to their Unicode equivalent. Without it, a value containing </script> could break out of the JSON block and inject arbitrary HTML — a real risk if any field comes from user input.
Test your structured data with Google’s Rich Results Test before deploying.
Common mistakes and how to spot them
Missing metadataBase for OG images. If you set openGraph.images: '/og.png' without metadataBase in the root layout, Next.js errors at build time. Set it once and forget it.
Exporting both metadata and generateMetadata from the same segment. Next.js throws a build error, but only for the affected route. The rest of the app still builds, so the bug can hide until you navigate to that specific page.
next/script for JSON-LD. This doesn’t crash anything — the structured data just may not appear in the HTML that crawlers receive, depending on when the script executes. It’s easy to miss until you run the Rich Results Test and see no structured data detected.
OG image cached after a change. Social platforms cache og:image aggressively. After updating opengraph-image.tsx, use each platform’s own URL debugger or share preview tool to force a refresh.
| Symptom | Likely cause | What to check |
|---|---|---|
Source HTML has no <title> |
No metadata export on the page |
Check the route hierarchy; the tag may need to be on the page, not just the layout |
og:image missing from source |
No file convention and no openGraph.images |
Add opengraph-image.tsx or set openGraph.images explicitly |
| Build error for OG image path | Missing metadataBase |
Add metadataBase to root layout metadata |
| JSON-LD absent in Rich Results Test | Using next/script |
Switch to a native <script> tag in JSX |
Stop treating SEO as a finishing step
A common pattern is to build the whole site and bolt on meta tags at the end. For static pages that works fine. For content-driven routes — blogs, e-commerce, docs — the metadata is tightly coupled to the data model, and setting it up after the fact means going back through every dynamic route.
The practical step: when you add a new route, immediately decide whether it needs static or dynamic metadata and add the right export alongside the page component. It takes a few minutes and avoids the session where you notice every page shows “MySite | MySite” in search results because title.template inherited an empty child title.
There’s one subtlety worth keeping in mind as layouts get more complex: Next.js shallow-merges metadata across layout hierarchies. Setting openGraph.title in a child page without also setting openGraph.description silently drops the parent’s openGraph.description. The fix is to spread the parent values when you extend them. Coming up in this series: how data fetching patterns interact with static vs. dynamic rendering decisions.