Skip to Content
BlogNext.js 16 SEO: The Complete Guide to Ranking Higher

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

seo-ranking

📸

Pexels

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.

app/layout.tsx
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:

app/page.tsx
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:

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.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 🤯:

lib/posts.ts
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:

app/layout.tsx
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:

app/about/page.tsx
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.

app/blog/[slug]/page.tsx
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:

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' 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:

app/sitemap.ts
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:

app/robots.ts
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.

app/blog/[slug]/page.tsx
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 &lt;head&gt; they need. Best of both worlds.


Putting It All Together

Here’s what a well-configured root layout looks like with everything in place:

app/layout.tsx
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 metadataBase first, always
  • Use export const metadata for 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!

Last updated on