← All articles
Technical SEO11 min read

Why Your Next.js Site Has SEO Problems (And How to Fix Each One)

Next.js App Router ships powerful metadata APIs and several silent failure modes. Every common SEO bug, with code verified against Next.js 16.


Next.js does not make your site bad at SEO. It makes a specific set of mistakes very easy to commit and very hard to notice, because almost all of them fail silently: the page renders, the build passes, the browser shows the right thing, and the HTML that Googlebot receives is missing something you assumed was there. Below are the failures that show up most often in App Router codebases, the mechanism behind each, and working fixes. The code here is checked against Next.js 16 — several of these APIs changed shape between 15 and 16, and tutorials written for older versions will now produce type errors or runtime bugs.

The version problem, first

Three changes matter before you copy anything from an older guide:

  • params and searchParams are Promises. Next.js 15 made them async with a temporary synchronous compatibility shim. Next.js 16 removed the shim. Any params.slug without an await is now broken, in pages, layouts, route handlers, and generateMetadata.
  • params and id in image routes are Promises too. As of 16, the default-exported function in opengraph-image.tsx, twitter-image.tsx, icon.tsx and apple-icon.tsx receives params as a Promise, and id as a Promise when you use generateImageMetadata. Confusingly, generateImageMetadata itself still receives synchronous params.
  • viewport and themeColor left metadata in 13.2. They belong in a separate viewport export or generateViewport function. Putting themeColor in the metadata object is deprecated and does nothing useful.

Also relevant to redirect handling: middleware.ts is deprecated in 16 in favour of proxy.ts, with the named export renamed from middleware to proxy, and no edge runtime support.

Metadata basics, and why static beats dynamic

For a route whose metadata does not depend on data, export a static object. Next.js resolves it at build time and it costs nothing at request time.

// app/pricing/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Pricing',
  description: 'Simple per-seat pricing with a free tier. No card required.',
}

export default function Page() {
  return <main>{/* ... */}</main>
}

For data-dependent routes, use generateMetadata. Note the awaited params — this is the Next.js 16 shape:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return {}

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.date,
    },
  }
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return <article>{/* ... */}</article>
}

Next.js 16 also generates global type helpers, so you can drop the inline Promise types:

export async function generateMetadata(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  // ...
}

PageProps and LayoutProps are globally available after next dev, next build, or next typegen — no import needed.

Two rules worth internalising. You cannot export both metadata and generateMetadata from the same segment. And that duplicate getPost call above is not a duplicate fetch: fetch requests are memoized across generateMetadata and the page render, and for non-fetch data sources you wrap the loader in React's cache() to get the same effect.

metadataBase: the bug that only bites self-hosters

This is the single most common production defect in App Router apps, and it is invisible on Vercel.

Relative URLs in metadata — an OG image at /og.png, a canonical at /blog/x — need a base to become absolute. metadataBase supplies it:

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: { default: 'Acme', template: '%s | Acme' },
  openGraph: { images: '/og.png' },
}

Omit it and Next.js falls back. Reading the resolver in [email protected] (dist/lib/metadata/resolvers/resolve-url.js), the fallback order for social images in production is: the Vercel preview URL if you are on a preview deployment, then metadataBase, then VERCEL_PROJECT_PRODUCTION_URL, then — if none of those exist — http://localhost:${process.env.PORT || 3000}.

On Vercel, VERCEL_PROJECT_PRODUCTION_URL is set, so the missing metadataBase is papered over and nobody notices. Self-hosted behind nginx, none of those env vars exist, and your production og:image becomes:

<meta property="og:image" content="http://localhost:3000/og.png" />

Every share preview on X, LinkedIn, Slack and Facebook renders blank. The build does log a warning once, which is easy to miss in CI output:

metadataBase property in metadata export is not set for resolving social
open graph or twitter images, using "http://localhost:3000".

Note that the docs describe a missing metadataBase with a relative URL as a build error; in practice, for social image fields, the runtime warns and falls back rather than failing. Do not rely on the build to catch this. Set it explicitly, from an environment variable so preview and production differ correctly:

metadataBase: new URL(
  process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
),

Canonicals via alternates.canonical

Canonicals go in alternates.canonical. With metadataBase set, a relative value resolves to an absolute URL, which is what you want — see canonical tag mistakes for why relative and inconsistent canonicals cause trouble.

export const metadata: Metadata = {
  alternates: {
    canonical: '/pricing',
    languages: { 'en-US': '/en-US/pricing', 'de-DE': '/de-DE/pricing' },
  },
}
<link rel="canonical" href="https://example.com/pricing" />
<link rel="alternate" hreflang="en-US" href="https://example.com/en-US/pricing" />

There is a neat trick worth knowing. A value starting with ./ is resolved against the current route's pathname, so putting this in your root layout gives every page a self-referencing canonical without touching each route:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  alternates: { canonical: './' },
}

The trap that follows: metadata merging is shallow. If a child segment exports alternates: { languages: {...} } without repeating canonical, the whole alternates object is replaced and that page loses its canonical entirely. Same hazard applies to openGraph and robots — a child that sets openGraph.title and nothing else wipes the parent's openGraph.images. This is by design, documented, and still catches people constantly.

The "use client" trap

metadata, generateMetadata, viewport and generateViewport are only supported in Server Components, because metadata must resolve on the server before the page renders in order to reach the initial HTML.

The failure sequence is familiar. Someone needs a useState at the top of a page, adds 'use client' to page.tsx, and the metadata export is now rejected. The path of least resistance is to delete the export and move on. Now that route inherits whatever the root layout says, and you have a section of your site where every page shares one title and one description.

The fix is to keep the page a Server Component and push interactivity down into a child:

// app/dashboard/page.tsx  — server component
import type { Metadata } from 'next'
import { DashboardClient } from './dashboard-client'

export const metadata: Metadata = {
  title: 'Dashboard',
  description: 'Your projects, audits and saved keyword lists in one place.',
}

export default function Page() {
  return <DashboardClient />
}
// app/dashboard/dashboard-client.tsx
'use client'
import { useState } from 'react'

export function DashboardClient() {
  const [tab, setTab] = useState('overview')
  // ...
}

'use client' marks a boundary, not a file-level opt-out — everything imported below it becomes client code. The rule of thumb: the 'use client' directive should almost never appear in a page.tsx or layout.tsx. Push it to the leaf that actually needs hooks or event handlers.

For gated app routes this matters less, since you probably want them noindex anyway. For anything public it is the difference between a page having its own identity in search and not.

Missing and duplicated H1s

Two structural bugs that layouts encourage.

Duplicated H1 happens when a layout renders a site or section title as <h1> and each page renders its own <h1> too. Every page then has two, and the one search engines and screen readers hit first is the site name, which is the least useful string available.

Missing H1 is the mirror image: pages built from a hero component that was styled with a <div> and utility classes because it looked right, so there is no <h1> at all.

Neither is a ranking penalty in any direct sense, and Google has been explicit that it handles multiple or absent H1s. That is not a reason to ignore it — heading structure is load-bearing for assistive technology and for anything parsing your page into an outline, which increasingly includes AI retrieval systems. Put the site name in the layout as a <p> or a <span> inside your nav, and let each page own exactly one <h1>.

robots.ts and sitemap.ts

Both are file conventions in the root of app/, and both are special Route Handlers that are cached by default unless they touch request-time APIs.

// app/robots.ts
import type { MetadataRoute } from 'next'

const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/dashboard/', '/api/'] },
      { userAgent: ['Applebot', 'Bingbot'], disallow: '/' },
    ],
    sitemap: `${base}/sitemap.xml`,
    host: base,
  }
}
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = process.env.NEXT_PUBLIC_SITE_URL!
  const posts = await getAllPosts()

  return [
    { url: base, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
    ...posts.map((post) => ({
      url: `${base}/blog/${post.slug}`,
      lastModified: new Date(post.updated),
      changeFrequency: 'monthly' as const,
      priority: 0.7,
    })),
  ]
}

Sitemap URLs must be absolute — metadataBase does not apply here. Two things people get wrong: a robots.ts that hardcodes a localhost sitemap URL, and sitemaps that list URLs which redirect or return 404, which wastes crawl budget and undermines the sitemap as a canonical signal (redirect chains covers the cost). If you split large sitemaps with generateSitemaps, note the Next.js 16 breaking change: the id passed to the sitemap function is now a Promise.

// app/product/sitemap.ts
export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }]
}

export default async function sitemap({ id }: { id: Promise<number> }) {
  const sitemapId = await id
  // ...
}

opengraph-image conventions

Drop opengraph-image.png in any route segment and Next.js emits og:image plus type, width and height automatically, with the more specific file winning over ancestors. Add opengraph-image.alt.txt beside it for og:image:alt. Size limits are enforced at build time: 8MB for opengraph-image, 5MB for twitter-image.

For generated images, export a default function returning ImageResponse from next/og. This is the Next.js 16 signature, with params awaited:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'

export const alt = 'Blog post'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          width: '100%',
          height: '100%',
          alignItems: 'center',
          padding: 80,
          fontSize: 64,
          background: '#0b0b0f',
          color: 'white',
        }}
      >
        {post.title}
      </div>
    ),
    { ...size }
  )
}

ImageResponse runs a subset of CSS — flexbox works, display: grid does not, and any element with multiple children needs an explicit display: flex. Also worth knowing: file-based metadata takes priority over the metadata object, so an opengraph-image file will override an openGraph.images value you set in code.

Streaming, Suspense, and what crawlers actually receive

Since 15.2, metadata streams. For dynamically rendered pages, Next.js sends UI first and appends the metadata tags to the <body> when generateMetadata resolves, rather than blocking the response on it.

For bots that execute JavaScript and read the full DOM — Googlebot among them — this is fine, and Next.js states it has verified the behaviour. For HTML-limited bots that only parse the initial HTML, streaming would be fatal, so Next.js detects them by user agent and blocks the response until metadata resolves, putting it in <head> as before. The default list includes Bingbot, Twitterbot, Slackbot and several Google crawlers. You can override it:

// next.config.ts
import type { NextConfig } from 'next'

const config: NextConfig = {
  htmlLimitedBots: /MySpecialBot|SimpleCrawler/,
}

export default config

Setting htmlLimitedBots: /.*/ disables streaming metadata entirely. Note that supplying this option replaces the default list rather than extending it, which is an easy way to accidentally stop blocking for Twitterbot.

The subtler streaming issue is status codes. Once a Suspense fallback renders, the response has committed to 200 OK and headers are already sent. If notFound() fires after that point, Next.js cannot retroactively return 404 — instead it injects <meta name="robots" content="noindex"> into the stream. That works, but you are serving a 200 for a missing page, which is a soft 404 as far as reporting is concerned. Similarly, a mid-stream redirect() degrades to a client-side redirect rather than a real HTTP redirect.

The fix is to do existence checks before anything suspends:

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const exists = await checkSlugExists(slug) // fast, cheap
  if (!exists) notFound()                    // real 404, before any Suspense

  return (
    <Suspense fallback={<p>Loading…</p>}>
      <PostContent slug={slug} />
    </Suspense>
  )
}

If you are on Cache Components, there is one more thing to watch: HTML-limited bots skip the prerendered shell and render the page dynamically so metadata can land in <head>. If your shell depends on data only available at build time, a page that loads perfectly for humans can fail for a crawler. Make sure anything the shell needs is reachable at request time.

A five-minute verification pass

Do not trust the browser's element inspector — it shows the hydrated DOM, not what a crawler received. Look at the raw response:

# What the initial HTML actually contains
curl -s https://example.com/pricing | grep -iE '<title>|name="description"|rel="canonical"|og:image'

# What an HTML-limited bot receives (blocking metadata path)
curl -s -A 'Twitterbot/1.0' https://example.com/pricing | head -c 3000

# Status code for a URL that should 404
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/blog/does-not-exist

If og:image says localhost, you have the metadataBase bug. If the title is identical across a whole route group, you probably have the 'use client' bug. If a missing page returns 200, you have the streaming status-code issue.

Running that grep across every route is exactly the kind of thing worth automating. SEO Fix Agent audits a page's rendered HTML against roughly 30 weighted checks — head tags, canonicals, headings, structured data, robots and sitemap reachability — and its Prompt Studio compiles the findings into a fix prompt you can paste straight into a coding agent, which pairs naturally with the Claude Code workflow for shipping the changes.

#nextjs#app-router#metadata#technical-seo#javascript-seo

Audit your page, then ship the fix

SEO Fix Agent runs 30+ technical, content and AI-search checks on a page, then compiles every finding into a severity-ordered prompt your coding agent can execute. 75 free credits, no card.

Start free →

Keep reading