Next.js Content-Site SEO: The Footguns to Check First

Next.js does not break SEO, but Next.js 16 has real footguns: async params, streaming metadata, dynamic routes. Use this metadata, sitemap.ts, and view-source checklist before you ask Google to crawl.

Most “Next.js SEO is broken” posts are really “I forgot to export metadata”, “I left a route dynamic when it should be static”, or (since late 2025) “I never migrated to the async params API”. The framework is fine. The defaults bite. This is the exact list to check before you tell Google to index a Next.js 16 content site, with the App Router code for each step.

TL;DR

  • Run next build. Every public article route must print (Static) or (SSG), not ƒ (Dynamic). A dynamic content route usually means a missing generateStaticParams.
  • On Next.js 16, params and searchParams are Promises. You must await params in generateMetadata, the page, and generateStaticParams callbacks, or the build throws.
  • Export generateMetadata (canonical + alternates.languages + Open Graph) from every dynamic page, add app/sitemap.ts and app/robots.ts, and emit Article JSON-LD server-side.
  • curl -sL a deployed article: the title, description, canonical, hreflang, JSON-LD, and the body text must all be in the raw HTML, not injected by client JS.

Why static HTML still wins in 2026

Googlebot renders JavaScript, but rendering is a second pass that runs on its own schedule and burns crawl budget. A page that ships the title, description, canonical, structured data, and full body in the initial HTML response gets discovered, indexed, and cached faster than one that depends on hydration or client fetches. For a new content site fighting for crawl budget, that gap is the difference between “indexed in days” and “discovered, currently not indexed” for weeks. Next.js 16 can produce fully static HTML out of the box. The footguns below are the things that quietly turn it back into a client-rendered shell.

How to tell you have a problem

  • Google Search Console reports “Discovered - currently not indexed” or “Crawled - currently not indexed” for chunks of your site.
  • view-source: on a page shows the body missing or wrapped in an empty app shell, with content arriving only after JS runs.
  • Lighthouse SEO is below 100 with “Document does not have a meta description” or “Links are not crawlable”.
  • Pages have a duplicate <title> or no canonical tag.
  • next build marks article routes as ƒ (Dynamic) instead of (Static).

Quick verdict

If view-source: on a deployed article shows the full body plus the metadata tags, you are 90% of the way there. Add a sitemap, robots, and JSON-LD and you are done.

Before you start

  • This assumes the App Router on Next.js 16 (16.2.x as of June 2026), where Turbopack is the default bundler for both next dev and next build. The Pages Router still works but is in maintenance; new content sites should use the App Router.
  • Know your deploy target (Vercel, Cloudflare Workers/Pages, Netlify) so you know whether you are shipping a static export or a server runtime.
  • Pick one sample article slug for testing.

The async params footgun (Next.js 15 → 16)

This is the single most common thing that breaks an upgraded content site. In Next.js 15 the synchronous params access was deprecated with a warning. In Next.js 16 it is removed: params, searchParams, cookies(), and headers() are async-only. If your generateMetadata or page still destructures params synchronously, the build errors or the route silently falls back to dynamic rendering. Type params as a Promise and await it everywhere.

// ❌ Next.js 14 style — breaks on 16
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const article = await getArticle(params.slug); // params is a Promise now
}

// ✅ Next.js 16 style
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props) {
  const { slug } = await params;
  const article = await getArticle(slug);
}

Next.js ships a codemod (npx @next/codemod@latest next-async-request-api .) that rewrites most call sites automatically. Run it, then grep for any remaining params. access you destructure without await.

Step by step

1. Verify your routes are static

Run next build and read the route table. The leading symbol tells you the rendering strategy:

SymbolMeaningGood for content?
Static (prerendered to HTML at build)Yes
SSG with generateStaticParams (per-slug static)Yes
ƒDynamic (rendered per request on the server)No, for public articles
Route (app)                              Size  First Load JS
┌ ○ /                                    140 B       91.2 kB
├ ● /[lang]/articles/[slug]             1.34 kB     94.6 kB   ← ● per-slug static (good)
├ ƒ /api/search                          0 B            0 B   ← ƒ dynamic API (fine)
└ ƒ /articles/preview                    3.21 kB     97.1 kB   ← ƒ dynamic content (red flag)

A ƒ on a public article almost always means a missing generateStaticParams, or that the page reads cookies()/searchParams/headers() and forced itself dynamic.

2. Export generateMetadata from every page

app/[lang]/articles/[slug]/page.tsx — note the Promise types and the metadataBase set once in the root layout so relative URLs resolve:

import type { Metadata } from 'next';
import { getArticle, getAllSlugs } from '@/lib/content';

type Props = { params: Promise<{ slug: string; lang: string }> };

export async function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticle(slug);
  return {
    title: article.title,
    description: article.description,
    alternates: {
      canonical: `https://yourdomain.com/en/articles/${article.urlSlug}/`,
      languages: {
        'en-US': `https://yourdomain.com/en/articles/${article.urlSlug}/`,
        'zh-CN': `https://yourdomain.com/zh/articles/${article.urlSlug}/`,
        'x-default': `https://yourdomain.com/en/articles/${article.urlSlug}/`,
      },
    },
    openGraph: {
      title: article.title,
      description: article.description,
      url: `https://yourdomain.com/en/articles/${article.urlSlug}/`,
      type: 'article',
      publishedTime: article.publishedAt.toISOString(),
    },
    twitter: { card: 'summary_large_image', title: article.title },
  };
}

Set metadataBase: new URL('https://yourdomain.com') in app/layout.tsx once. Without it, relative URLs in Open Graph images throw a build error, and absolute-vs-relative canonical bugs creep in.

3. Add app/sitemap.ts

List every published article with its language alternates:

import { MetadataRoute } from 'next';
import { getAllArticles } from '@/lib/content';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const articles = await getAllArticles();
  return [
    { url: 'https://yourdomain.com/', changeFrequency: 'weekly', priority: 1 },
    ...articles.map((a) => ({
      url: `https://yourdomain.com/en/articles/${a.urlSlug}/`,
      lastModified: a.updatedAt ?? a.publishedAt,
      changeFrequency: 'monthly' as const,
      priority: 0.6,
      alternates: {
        languages: {
          'en-US': `https://yourdomain.com/en/articles/${a.urlSlug}/`,
          'zh-CN': `https://yourdomain.com/zh/articles/${a.urlSlug}/`,
        },
      },
    })),
  ];
}

A single sitemap.xml should stay under 50,000 URLs and 50 MB. Past that, return an array of sitemaps via generateSitemaps and reference them from a sitemap index.

4. Add app/robots.ts

import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: '*', allow: '/' }],
    sitemap: 'https://yourdomain.com/sitemap.xml',
    host: 'https://yourdomain.com',
  };
}

5. Emit JSON-LD server-side

Render the <script> from the server component so it lands in the initial HTML. Remember to await params:

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params;
  const a = await getArticle(slug);
  const ld = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: a.title,
    description: a.description,
    datePublished: a.publishedAt,
    dateModified: a.updatedAt ?? a.publishedAt,
    author: { '@type': 'Organization', name: a.author },
    mainEntityOfPage: `https://yourdomain.com/en/articles/${a.urlSlug}/`,
  };
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }}
      />
      <article>{/* article body */}</article>
    </>
  );
}

Validate the output with Google’s Rich Results Test before you trust it.

6. Lock the trailing-slash policy in next.config

Pick one and never change it. The canonical, the sitemap URL, and the live URL must all agree, because Google treats /foo and /foo/ as different URLs:

/** @type {import('next').NextConfig} */
module.exports = {
  trailingSlash: true,        // pick one, keep it forever
  poweredByHeader: false,
  async redirects() {
    return [{ source: '/blog/:slug', destination: '/articles/:slug/', permanent: true }];
  },
};

7. View-source the deployed page

Confirm the metadata and the body are in the raw HTML, not added by client JS:

curl -sL https://yourdomain.com/en/articles/test-slug/ \
  | grep -E '<title>|description|canonical|hreflang|application/ld\+json'

Then confirm the body is server-rendered too:

curl -sL https://yourdomain.com/en/articles/test-slug/ | grep "a key phrase from the article"

If that phrase returns a line, you are shipping crawl-friendly HTML.

8. Submit the sitemap and verify

Submit sitemap.xml in Search Console, then run Lighthouse on three sample articles and confirm SEO = 100.

The streaming-metadata footgun

Since Next.js 15.2, when a route is not fully prerendered (it reads runtime data), generateMetadata can resolve after the initial UI is sent, and the metadata tags are appended near the end of the response instead of sitting in <head>. Googlebot executes JS and reads the full DOM, so it sees them. But HTML-limited bots that do not run JSfacebookexternalhit, some link-preview crawlers — only read <head>, so they can miss your Open Graph tags and render a blank share card.

The fix is upstream: keep article routes fully static (steps 1 and 2) so metadata stays in <head>. If you genuinely need runtime data, you can force blocking metadata for all bots in next.config:

// next.config.ts — make metadata block for every user agent
import type { NextConfig } from 'next';
const config: NextConfig = { htmlLimitedBots: /.*/ };
export default config;

Use this only if you must. It costs you the TTFB win that streaming metadata provides.

Common pitfalls

  • Reading params/searchParams/cookies() synchronously on Next.js 16. All are Promises now; await them or the build fails.
  • Exporting metadata or generateMetadata from a client component ('use client'). It is silently ignored. Metadata must come from a server component.
  • Forgetting generateStaticParams on a [slug] route, so it renders dynamically and shows up as ƒ in the build.
  • Missing metadataBase, which throws on relative Open Graph image URLs and produces wrong absolute URLs.
  • Shipping the LCP image through next/image without priority, dragging LCP past the 2.5 s “good” threshold and hurting Core Web Vitals.
  • Trailing-slash mismatch between sitemap, canonical, and the live URL.
  • Disallowing /_next/static/ in robots, which blocks Google from your CSS and JS and breaks rendering.
  • Wrapping server-rendered content in <Suspense> with no static fallback, so crawlers without JS see only a spinner.

FAQ

  • Does Google really render JS?: Yes, but on a delayed second pass that consumes crawl budget. Static HTML still wins on indexing speed and crawl efficiency, which matters most for new sites.
  • Do I need a separate hreflang config?: No. alternates.languages in your metadata generates the <link rel="alternate" hreflang> tags, and app/sitemap.ts can mirror them.
  • Pages Router or App Router for a new content site?: App Router. The metadata, sitemap.ts, and robots.ts file conventions only exist there, and the Pages Router is in maintenance as of Next.js 16.
  • ISR or pure static?: Pure static if the content rarely changes; ISR (revalidate) if you want to push edits without a full redeploy. Both are SEO-equivalent because both serve cached HTML to the crawler.
  • Is next/image good for SEO?: Yes, when configured. It emits responsive srcset, lazy loading, and explicit dimensions, which help LCP and CLS. Just remember priority on the LCP image.
  • Should I noindex tag and pagination pages?: Noindex thin tag pages until they have substantive listings; pagination can stay indexed if each page lists unique content.

Tags: #Indie dev #Next.js #SEO #Technical SEO #Canonical #Core Web Vitals