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 missinggenerateStaticParams. - On Next.js 16,
paramsandsearchParamsare Promises. You mustawait paramsingenerateMetadata, the page, andgenerateStaticParamscallbacks, or the build throws. - Export
generateMetadata(canonical +alternates.languages+ Open Graph) from every dynamic page, addapp/sitemap.tsandapp/robots.ts, and emitArticleJSON-LD server-side. curl -sLa 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 buildmarks 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 devandnext 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:
| Symbol | Meaning | Good 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 JS — facebookexternalhit, 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;awaitthem or the build fails. - Exporting
metadataorgenerateMetadatafrom a client component ('use client'). It is silently ignored. Metadata must come from a server component. - Forgetting
generateStaticParamson 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/imagewithoutpriority, 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.languagesin your metadata generates the<link rel="alternate" hreflang>tags, andapp/sitemap.tscan mirror them. - Pages Router or App Router for a new content site?: App Router. The metadata,
sitemap.ts, androbots.tsfile 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/imagegood for SEO?: Yes, when configured. It emits responsivesrcset, lazy loading, and explicit dimensions, which help LCP and CLS. Just rememberpriorityon 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.
Related
- Sitemap and robots.txt Basics in Next.js
- Next.js Image Optimization Basics
- Submit a New Site to Google in 2026
- When Next.js is wrong for content
- Next.js App Router concepts
Tags: #Indie dev #Next.js #SEO #Technical SEO #Canonical #Core Web Vitals