DEV Community

Cover image for How to Optimize Next.js for SEO (4 Lines of Code Fixed My Indexing Problem)
Mitu Das
Mitu Das

Posted on • Originally published at ccbd.dev

How to Optimize Next.js for SEO (4 Lines of Code Fixed My Indexing Problem)

I spent 3 hours debugging why Google couldn't see my Next.js app. Pages were live, content was rendering, users could click around fine, but Search Console kept telling me my pages were basically blank. The fix, once I found it, was four lines of code in generateMetadata.

Turns out most "my Next.js site isn't ranking" problems aren't algorithm mysteries. They're plumbing issues: metadata never generated, sitemaps never built, structured data never added. If you're trying to optimize Next.js for SEO, the fixes are smaller and more mechanical than people expect. Here's how I fixed mine, with code you can paste in today.

Metadata That Actually Renders Per-Page

If you want to optimize Next.js for SEO, this is step one. The most common mistake I see (and made myself) is exporting a single static metadata object from layout.tsx and calling it done. That gives every page on your site the same title and description, which Google treats as low-effort duplicate content.

The App Router gives you generateMetadata, an async function that runs per-route and can pull from your actual data:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

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

  return {
    title: `${post.title} | My Blog`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
    },
    alternates: {
      canonical: `https://example.com/blog/${params.slug}`,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Result: every blog post gets its own title, description, and OG image instead of inheriting the homepage's. I checked this with curl against the rendered HTML (not just the dev tools DOM) to confirm the tags were actually in the server response, not injected client-side after hydration. That distinction matters because crawlers don't always wait for JS.

Sitemaps and Robots.txt as Code, Not Afterthoughts

Next.js SEO isn't just about metadata tags, crawlers also need a map to your content. Hand-writing a sitemap.xml that you forget to update is how half the internet ends up with 404s in Search Console. Next.js lets you generate both as TypeScript files that run at build time, so they're always in sync with your actual routes.

// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();

  const postEntries = posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    ...postEntries,
  ];
}
Enter fullscreen mode Exit fullscreen mode
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/admin", "/api"],
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}
Enter fullscreen mode Exit fullscreen mode

Result: /sitemap.xml and /robots.txt are now generated at build time from your actual content source, so a new blog post automatically shows up in the sitemap on the next deploy. No manual XML editing, no stale entries.

Structured Data Without the JSON-LD Headache

This is where most tutorials get hand-wavy. Structured data (JSON-LD) is what gets you rich results: star ratings, FAQ dropdowns, breadcrumb trails in search results. But writing it by hand for every page type is tedious and error-prone, and a single malformed schema can get the whole block ignored by Google's validator.

A plain script tag works fine:

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    datePublished: post.publishedAt,
    author: { "@type": "Person", name: post.author },
  };

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>{post.title}</h1>
      {/* rest of content */}
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

That works for one schema type. Once you have blog posts, products, FAQs, and breadcrumbs all needing different schemas, hand-rolling each one gets old fast. I ended up reaching for power-seo, a small package that wraps the common schema types (Article, Product, FAQ, Breadcrumb) so you pass in your data and get valid JSON-LD back, instead of copy-pasting schema.org docs into objects every time:

import { generateArticleSchema } from "power-seo";

const jsonLd = generateArticleSchema({
  title: post.title,
  publishedAt: post.publishedAt,
  author: post.author,
  image: post.coverImage,
});
Enter fullscreen mode Exit fullscreen mode

It's not magic. It's the same JSON-LD you'd write by hand, just validated and typed so you don't ship a typo that breaks rich results for a whole content type.

Verifying It Actually Worked

Don't trust your eyes in the browser. View-source and Google's tools tell the real story.

curl -s https://example.com/blog/my-post | grep -o '<title>.*</title>'
Enter fullscreen mode Exit fullscreen mode

Then run the page through Google's Rich Results Test and check Search Console's URL Inspection tool a few days after deploy. If the rendered HTML has your tags but Search Console still shows the old version, that's a caching/indexing delay, not a code bug. Don't panic and start ripping out working metadata code.

What I Learned

  • Per-route generateMetadata beats a static layout-level object every time. Duplicate titles are an easy, invisible SEO leak.
  • Sitemaps and robots.txt as code mean they can't go stale, because they're generated from the same data your pages render from.
  • Structured data is worth doing even when it feels like busywork. Rich results are one of the few SEO wins you can verify directly in search results.
  • Always verify with curl or view-source, not just dev tools. Client-side-only metadata is invisible to crawlers that don't execute JS reliably.

None of this requires a rewrite. If you're trying to optimize Next.js for SEO on an existing project, you can ship generateMetadata, sitemap.ts, and one JSON-LD block in an afternoon and see the difference in Search Console within a week.

If you want to try this approach end to end, here's the repo: https://ccbd.dev/blog/how-to-optimize-nextjs-for-seo-with-power-seo-core

Your Turn

What's the most annoying Next.js SEO bug you've run into, and how long did it take you to find it? Drop it in the comments, I'm collecting horror stories.

Top comments (0)