Next.js 16 SEO: The Complete Guide to Ranking Higher 🚀

You spend weeks building a beautiful Next.js app. The UI is clean, the code is solid, performance is great. You deploy it and… Google ignores it completely 😤.
I’ve been there. The problem almost every time? The metadata is either missing, broken, or still has the default Next.js placeholder values. Search engines don’t care how pretty your app looks — they read your <head> tags.
Good news is that Next.js 16 has one of the best metadata APIs I’ve ever worked with. Let me show you how to use it properly so your site actually ranks.
1. Set metadataBase First — Seriously 🚨
Before anything else, do this. It’s the one thing most developers skip and it silently breaks everything.
metadataBase tells Next.js the base URL of your site. Without it, any relative URL you put in your OG images or canonical tags just won’t work — they’ll either be empty or wrong in production.
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang='en'>
<body>{children}</body>
</html>
)
}Set metadataBase in your root app/layout.tsx only. Once it’s there, every
relative URL you use in metadata across your entire app will resolve
correctly.
2. Static vs Dynamic Metadata
Next.js gives you two ways to add metadata — and you’ll use both depending on the page.
Static Pages (Home, About, etc.)
For pages where the content doesn’t change, just export a metadata object:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Home',
description: 'Welcome to my site.',
}
export default function HomePage() {
return <main>...</main>
}Dynamic Pages (Blog posts, products, etc.)
For pages with dynamic routes like /blog/[slug], use generateMetadata to fetch the data and return metadata based on it:
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.description,
}
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return <article>...</article>
}Notice that both generateMetadata and the page component call getPost(slug). You might think that’s two database calls — but it’s not! Wrap your data fetching function with React’s cache() and it deduplicates automatically 🤯:
import { cache } from 'react'
export const getPost = cache(async (slug: string) => {
// This only executes ONCE even if called multiple times
return db.query.posts.findFirst({ where: eq(posts.slug, slug) })
})3. Title Templates ✨
This one is subtle but really important. Instead of manually writing "About | My Site" on every page, set a template once in the root layout:
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
title: {
template: '%s | My Site',
default: 'My Site', // used when a page has no title
},
description: 'Your site description here.',
}Now in any page, just set the short title:
export const metadata: Metadata = {
title: 'About', // becomes "About | My Site" automatically
}The %s gets replaced with whatever title you set on the child page. And if a page forgets to set a title, default kicks in. Clean, right?
4. Open Graph & Twitter Cards 🃏
These are the tags that control what shows up when someone shares your link on Twitter, Slack, WhatsApp, or LinkedIn. They also indirectly help SEO by improving click-through rates.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
url: `https://yourdomain.com/blog/${slug}`,
siteName: 'My Site',
type: 'article',
publishedTime: post.publishedAt,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
creator: '@yourtwitterhandle',
},
}
}That’s it. Next.js handles rendering all the right <meta> tags from this object.
5. Dynamic OG Images 🎨
You know those clean, branded preview images you see when someone shares a link? Next.js can generate them automatically per-page with zero extra infrastructure.
Create a file called opengraph-image.tsx inside any route folder:
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return new ImageResponse(
<div
style={{
background: '#0f0f0f',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: 80,
}}
>
<p style={{ color: '#888', fontSize: 24, margin: 0 }}>My Blog</p>
<h1 style={{ color: '#fff', fontSize: 64, margin: '16px 0 0' }}>
{post.title}
</h1>
</div>
)
}Next.js will automatically wire this up as the OG image for every /blog/[slug] page. No configuration needed — the file convention does it all 🙌.
The ImageResponse component uses a subset of CSS. Flexbox works great, but
CSS Grid is not supported. Stick to simple layouts and you’ll be fine.
6. Sitemap & Robots 🗺️
Google needs a sitemap to discover all your pages efficiently. Next.js makes this a one-file job.
Sitemap
Create app/sitemap.ts and return an array of your URLs:
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const postUrls = posts.map((post) => ({
url: `https://yourdomain.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [
{
url: 'https://yourdomain.com',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 1,
},
...postUrls,
]
}This generates a /sitemap.xml endpoint automatically. Submit that URL to Google Search Console and you’re done.
Robots
Create app/robots.ts to control what crawlers can and can’t access:
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
},
sitemap: 'https://yourdomain.com/sitemap.xml',
}
}This generates /robots.txt automatically. Super clean, no manual file to maintain.
7. Canonical URLs
If your content appears on multiple URLs (e.g., with and without trailing slash, or with pagination), you need canonical tags to tell Google which version is the “real” one.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
return {
alternates: {
canonical: `/blog/${slug}`, // relative — metadataBase handles the rest
},
}
}Because you set metadataBase earlier, this relative path resolves to https://yourdomain.com/blog/slug automatically. This is why metadataBase is so important 😄.
8. Streaming Metadata ⚡
This is one of my favorite Next.js 16 features and it’s a nice performance win. In older versions, Next.js would wait for all metadata to resolve before sending any HTML to the browser. That meant slow generateMetadata calls could delay your entire page load.
In Next.js 16, metadata is streamed separately from the page — the UI can start rendering while metadata is still being resolved.
The best part? You don’t have to do anything to enable it. It’s on by default.
The one thing to know: for bots like Googlebot, Twitterbot, and Bingbot, Next.js automatically disables streaming and waits for metadata to fully resolve. So your SEO is never compromised — the bots always get complete metadata in the initial HTML.
Streaming metadata means your pages feel faster to real users, and SEO bots
still get the full <head> they need. Best of both worlds.
Putting It All Together
Here’s what a well-configured root layout looks like with everything in place:
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
title: {
template: '%s | My Site',
default: 'My Site',
},
description: 'Your site description here.',
openGraph: {
siteName: 'My Site',
type: 'website',
},
twitter: {
card: 'summary_large_image',
creator: '@yourtwitterhandle',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang='en'>
<body>{children}</body>
</html>
)
}Every page you add after this will inherit these defaults and override only what it needs to. Clean, maintainable, and Google-friendly ✅.
Conclusion 🎉
SEO in Next.js isn’t complicated — it just needs to be set up properly. To summarize:
- Set
metadataBasefirst, always - Use
export const metadatafor static pages - Use
generateMetadata+React cache()for dynamic pages - Use title templates so you don’t repeat yourself
- Add Open Graph + Twitter tags for good social previews
- Generate your sitemap and robots.txt with file conventions
- Create per-route OG images with
opengraph-image.tsx - Streaming metadata in Next.js 16 handles performance automatically
That’s really all there is to it. Set this up once and your Next.js app will be in a much better shape for search engines.
Thanks for reading! ✨ If you have any questions or something I missed, drop a comment below.
Bye for now …
Comments
Leave a comment or reaction below!