Next.js Content-Site SEO: The Things That Bite

Next.js does not break SEO, but it has footguns. Use this metadata API, 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” 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 build output 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

  1. Verify your routes are static. Run next build and 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.

  1. Export metadata (or generateMetadata) 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 },
  };
}
  1. Add app/sitemap.ts that 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}/`,
          },
        },
      },
    ]),
  ];
}
  1. Add app/robots.ts referencing 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',
  };
}
  1. 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>
    </>
  );
}
  1. Set next.config.js trailing 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 }];
  },
};
  1. 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.

  1. Submit the sitemap in Search Console and confirm Lighthouse SEO = 100.

Implementation checklist

  • next build shows article routes as ○ Static.
  • generateMetadata exports canonical, openGraph, alternates from every dynamic page.
  • app/sitemap.ts and app/robots.ts exist 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 -sL confirms 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() or notFound() inside layouts and accidentally serving a 200 with empty content.
  • Forgetting generateStaticParams for [slug] routes — without it, dynamic segments will not prerender.
  • Letting next/image ship without priority on the LCP image, hurting LCP enough to drop rankings.
  • Trailing slash inconsistency between sitemap, canonical, and actual URLs — Google treats /foo and /foo/ as different URLs.
  • Blocking _next/static in robots.txt — it cuts off Google from your CSS and JS, hurting render and ranking.
  • Server-rendered content wrapped in a Suspense with no fallback HTML — 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.languages in 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/image good for SEO?: Yes, when configured. It produces responsive srcset, 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.

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