Most “Next.js SEO is broken” posts are really “I forgot to export metadata” or “I left a route dynamic when it should have been static”. The framework is fine. The defaults can bite. This is the list of things to check before you tell Google to index your site — every step has the exact App Router code you need.
Background
Search engines render JavaScript in 2026, but they prefer not to. Static HTML with the title, description, canonical, structured data, and content already in the initial response is rendered, ranked, and cached faster than a hydration-dependent payload. Next.js can produce that — if you set it up.
How to tell
- Google Search Console says “Indexed, though blocked by robots.txt” or “Discovered - currently not indexed” for chunks of your site.
- View-source on your pages shows the body content missing or wrapped in a
<div id="__next">shell. - Lighthouse SEO score is below 100 with “Document does not have a meta description” or “Links are not crawlable” warnings.
- Pages have duplicate
<title>or no canonical tag. next buildoutput marks article routes as Dynamic (λ) instead of Static (○).
Quick verdict
If view-source: on a deployed article shows the full body and metadata, you’re 90% of the way there. Add a sitemap, robots, and JSON-LD and you’re done.
Before you start
- App Router (not Pages Router) is preferred for new sites — examples below assume App Router.
- Deploy target known (Vercel, Cloudflare Pages, Netlify) so you know where static export lands.
- Sample article slug picked for testing.
Step by step
- Verify your routes are static. Run
next buildand read the output:
Route (app) Size First Load JS
┌ ○ / 140 B 91.2 kB
├ ○ /articles/[slug] 1.34 kB 94.6 kB ← ○ Static
├ λ /api/foo 0 B 0 B ← λ Dynamic (OK for API)
└ ƒ /articles/dynamic-route 3.21 kB 97.1 kB ← ƒ Dynamic (BAD for content)
Anything ƒ Dynamic on a public article is a red flag — usually a missing generateStaticParams.
- Export
metadata(orgenerateMetadata) from every page.app/articles/[slug]/page.tsx:
import type { Metadata } from 'next';
import { getArticle, getAllSlugs } from '@/lib/content';
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const article = await getArticle(params.slug);
return {
title: article.title,
description: article.description,
alternates: {
canonical: `https://yourdomain.com/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/articles/${article.urlSlug}/`,
type: 'article',
publishedTime: article.publishedAt.toISOString(),
},
twitter: { card: 'summary_large_image', title: article.title },
};
}
- Add
app/sitemap.tsthat lists every published article:
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.flatMap((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}/`,
},
},
},
]),
];
}
- Add
app/robots.tsreferencing the sitemap:
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: 'https://yourdomain.com/sitemap.xml',
host: 'https://yourdomain.com',
};
}
- Emit JSON-LD structured data in the article page. Use a
<script>server-side:
export default async function ArticlePage({ params }) {
const a = await getArticle(params.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/articles/${a.urlSlug}/`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }}
/>
<article>{/* … */}</article>
</>
);
}
- Set
next.config.jstrailing slash policy and decide once:
/** @type {import('next').NextConfig} */
module.exports = {
trailingSlash: true, // pick one and never change
poweredByHeader: false,
async redirects() {
return [{ source: '/blog/:slug', destination: '/articles/:slug/', permanent: true }];
},
};
- View-source on a deployed page. Verify these are all present in raw HTML, not added by client JS:
curl -sL https://yourdomain.com/articles/test-slug/ | grep -E '<title>|description|canonical|hreflang|application/ld\+json' | head
If the body text is also visible (curl -sL ... | grep "key phrase from article" returns the line), you’re shipping crawl-friendly HTML.
- Submit the sitemap in Search Console and confirm Lighthouse SEO = 100.
Implementation checklist
next buildshows article routes as○ Static.generateMetadataexports canonical, openGraph, alternates from every dynamic page.app/sitemap.tsandapp/robots.tsexist and resolve at production URLs.- JSON-LD is emitted server-side, not added by client JS.
- View-source on a deployed article contains title, description, canonical, JSON-LD, and the article body.
After-launch verification
- Lighthouse SEO score = 100 on at least 3 sample articles.
- Search Console URL Inspection on a sample shows “URL is on Google” within 1-2 weeks.
curl -sLconfirms hreflang pairs render in the initial HTML.
Common pitfalls
- Setting metadata in a client component — it will not work. Metadata must be exported from a server component (page or layout).
- Using
redirect()ornotFound()inside layouts and accidentally serving a 200 with empty content. - Forgetting
generateStaticParamsfor[slug]routes — without it, dynamic segments will not prerender. - Letting
next/imageship withoutpriorityon the LCP image, hurting LCP enough to drop rankings. - Trailing slash inconsistency between sitemap, canonical, and actual URLs — Google treats
/fooand/foo/as different URLs. - Blocking
_next/staticin robots.txt — it cuts off Google from your CSS and JS, hurting render and ranking. - Server-rendered content wrapped in a
Suspensewith nofallbackHTML — Google sees the spinner, not the content.
FAQ
- Does Google really render JS?: Yes, but with delay and selectively. Static HTML still wins on crawl budget, indexing speed, and ranking — especially for new sites.
- Do I need a separate hreflang config?: No —
alternates.languagesin your metadata generates the right<link rel="alternate" hreflang>tags automatically. - Should I use ISR or pure static?: Pure static if the content really does not change. ISR if you want to push edits without a redeploy. Both are SEO-equivalent.
- Is
next/imagegood for SEO?: Yes, when configured. It produces responsivesrcset, lazy loading, and correct dimensions — all LCP and CLS friendly. - Should I noindex tag and pagination pages?: Usually yes for tags until they have substantive content; pagination can stay indexed if each page has unique listings.
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