Next.js SEO in 2026: Metadata, Dynamic OG Images, JSON-LD and Sitemaps
The real SEO setup behind this blog: a metadata helper, per-post metadata, dynamic 1200×630 share images with next/og, JSON-LD structured data, sitemaps, and the bug that hid everything from Google.

Mohit Malaya Nandy
Software Engineer & Product Builder

On this page
You can write the best article on the internet, but if search engines see an empty page and social apps show a blank preview, almost nobody will find it. In Next.js, getting SEO right means four things: metadata, social share images, structured data and crawl files.
This tutorial uses the real code behind this blog. You will build a reusable metadata helper, per-post metadata, dynamic Open Graph images, JSON-LD and a sitemap, and learn about the bug that quietly broke SEO on this very site.
Step 0: Make sure your HTML has content#
Start here, because nothing else matters if this is wrong. Open your live page, choose View page source, and search for a sentence from your article. If it is not there, search engines and social apps cannot see it either.
On this site, the theme provider did this:
// The bug: nothing renders on the server
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null; // server HTML = empty pageBecause it wrapped the whole app, every page was sent to Google as an empty shell. The fix was to always render the content, and apply the saved theme with a tiny inline script before the page paints. Watch out for any component that returns null until it mounts.
Step 1: A reusable metadata helper#
Next.js builds <title>, meta tags, Open Graph and Twitter tags from a metadata object. A small helper keeps every page consistent:
const SITE = { name: 'Your Name', baseUrl: 'https://example.com', twitter: '@you' };
export function createMetadata({ title, description, pathname = '/', image = '/og-default.png', type = 'website', keywords }) {
const fullTitle = title ? `${title} | ${SITE.name}` : SITE.name;
const url = `${SITE.baseUrl}${pathname}`;
return {
title: fullTitle,
description,
keywords,
metadataBase: new URL(SITE.baseUrl),
alternates: { canonical: url },
openGraph: {
title: fullTitle,
description,
url,
siteName: SITE.name,
type,
images: [{ url: image, width: 1200, height: 630, alt: title }],
},
twitter: {
card: 'summary_large_image',
title: fullTitle,
description,
creator: SITE.twitter,
images: [image],
},
};
}metadataBaseturns relative image paths into the absolute URLs social platforms require.alternates.canonicaltells search engines the one official URL for the page, avoiding duplicate-content problems.summary_large_imagemakes X show the big image card instead of a small thumbnail.
Step 2: Metadata for each post#
Static pages can export metadata directly. Dynamic pages, like blog posts, use generateMetadata. Combine it with generateStaticParams so every post is built ahead of time:
import { notFound } from 'next/navigation';
import { POSTS } from '@/content/posts';
import { createMetadata } from '@/lib/seo/metadata';
export const dynamicParams = false; // unknown slugs return a real 404
export function generateStaticParams() {
return POSTS.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = POSTS.find((p) => p.slug === slug);
if (!post) return {};
return createMetadata({
title: post.title,
description: post.excerpt,
pathname: `/blog/${post.slug}`,
image: `/og/blog/${post.slug}`,
type: 'article',
keywords: post.tags,
});
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = POSTS.find((p) => p.slug === slug);
if (!post) notFound();
return <article>{/* … */}</article>;
}In current Next.js, params is a promise, so await it. Write descriptions of about 150 to 160 characters that say what the reader will get, since Google often shows them as the snippet.
Step 3: Dynamic Open Graph images#
The share image is the first thing people see when your link is posted. Next.js can generate one per post with ImageResponse, which turns JSX into a PNG. This route builds a branded 1200×630 banner from each post's title:
import { ImageResponse } from 'next/og';
import { POSTS } from '@/content/posts';
export const dynamic = 'force-static';
export const dynamicParams = false;
export function generateStaticParams() {
return POSTS.map((post) => ({ slug: post.slug }));
}
export async function GET(_request, { params }) {
const { slug } = await params;
const post = POSTS.find((p) => p.slug === slug);
if (!post) return new Response('Not found', { status: 404 });
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: 72,
background: '#0a0a0a',
color: '#fff',
}}
>
<div style={{ display: 'flex', color: '#f97316', fontSize: 28 }}>{post.category}</div>
<div style={{ display: 'flex', fontSize: 64, lineHeight: 1.1 }}>{post.title}</div>
<div style={{ display: 'flex', fontSize: 26, color: '#a1a1aa' }}>example.com</div>
</div>
),
{ width: 1200, height: 630 }
);
}Rules that save debugging time
- Every element with more than one child needs
display: 'flex'. The renderer supports a subset of CSS, mainly flexbox. - Images inside the banner must be PNG or JPG (as a URL or data URI). Convert WebP and SVG first; this site uses
sharpto do that at build time. - Use
<img>, notnext/image, insideImageResponse. - With
force-staticandgenerateStaticParams, images are built once duringnext build.
If you prefer conventions over routes, add an opengraph-image.jsx file next to page.jsx and Next.js wires up the meta tags for you. I used a route here so a post can opt out with a hand-made image in its metadata.
| Format | Works as a share image? |
|---|---|
| PNG / JPG at 1200×630 | Yes, everywhere |
| WebP | Not reliably |
| SVG | No |
Step 4: Structured data with JSON-LD#
JSON-LD describes your page in a format search engines understand: "this is a technical article by this person, published on this date". Next.js recommends rendering it in a <script> tag (JSON-LD guide):
export default function ArticleSchema({ post }) {
const url = `https://example.com/blog/${post.slug}`;
const data = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updated ?? post.date,
author: { '@type': 'Person', name: post.author.name, url: 'https://example.com' },
image: `https://example.com/og/blog/${post.slug}`,
keywords: post.tags.join(', '),
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
/>
);
}- Use TechArticle for tutorials and
BlogPostingfor personal posts. - Replacing
<with\u003cstops content like</script>in a title from breaking out of the tag. - Add a
FAQPageblock for posts with FAQ sections, and test everything in Google's Rich Results Test.
Step 5: Sitemap and robots.txt#
import { POSTS } from '@/content/posts';
export default function sitemap() {
const base = 'https://example.com';
return [
{ url: base, changeFrequency: 'weekly', priority: 1 },
{ url: `${base}/blog`, changeFrequency: 'weekly', priority: 0.8 },
...POSTS.map((post) => ({
url: `${base}/blog/${post.slug}`,
lastModified: post.updated ?? post.date, // real dates, not "now"
changeFrequency: 'monthly',
priority: 0.7,
})),
];
}export default function robots() {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
sitemap: 'https://example.com/sitemap.xml',
};
}Next.js serves these at /sitemap.xml and /robots.txt (sitemap, robots). Use each post's real updated date for lastModified. Setting it to the build time on every page tells search engines everything changed on every deploy, which makes the field useless.
Step 6: Verify on the live site#
- View the page source on the live URL and confirm the article text and meta tags are there.
- Open your OG image URL directly and check it is a 1200×630 PNG.
- Run the page through Google's Rich Results Test.
- Preview shares with LinkedIn Post Inspector and the Facebook Sharing Debugger, which also refresh cached previews.
- Submit your sitemap in Google Search Console.
Next.js SEO checklist#
- Server HTML contains the real content (no components that return null until mounted).
- Every page has a unique title, description and canonical URL.
- metadataBase is set, and the canonical domain matches the live domain.
- Each post has a 1200×630 PNG or JPG share image.
- JSON-LD describes articles, the author and FAQs, and passes the Rich Results Test.
- Sitemap uses real dates; robots.txt points to it.
- Dynamic routes use generateStaticParams, and unknown slugs return 404.
- Previews checked on the live site after deploying.
Frequently asked questions#
What size should an Open Graph image be?
1200 by 630 pixels is the standard. It displays well on X, LinkedIn, Facebook, WhatsApp and Slack. Use PNG or JPG; most social platforms do not display SVG or WebP previews.
Why does my shared link show the wrong title or image?
Usually because the live site serves different HTML than you expect (for example, the change was never deployed), or the platform cached an old preview. Check the page source on the live URL, then refresh the preview with LinkedIn Post Inspector or Facebook's Sharing Debugger.
Do client components hurt SEO in Next.js?
Not by themselves: client components are still rendered to HTML on the server. SEO breaks when a component returns null or waits for the browser before showing content, so the server HTML is empty.
Should I generate OG images dynamically?
For blogs, yes. Generating a branded image per post from its title is consistent and needs no design work. With generateStaticParams the images are built once at build time, so they cost nothing per request.
Does JSON-LD improve Google rankings?
Structured data does not directly raise rankings, but it helps search engines understand your page and makes it eligible for rich results such as FAQs, which can improve click-through rates.
Official references#
- Metadata and OG images, generateMetadata and ImageResponse, Next.js
- opengraph-image, sitemap and robots file conventions, Next.js
- JSON-LD guide, Next.js, and TechArticle, schema.org
- The Open Graph protocol
- Rich Results Test, Google
Found this useful? You can support my writing on GitHub Sponsors or with a coffee.


