treeru.com

Next.js SEO Score 100 — Metadata, Sitemap, and robots.txt

A PageSpeed SEO score of 100 is not difficult — it requires a few settings configured completely. The problem is that some items are easy to miss: canonical URLs, Open Graph images, JSON-LD structured data. This guide covers every SEO configuration needed in the Next.js App Router.

generateMetadata Pattern

In the Next.js App Router, metadata is set via the generateMetadata function. It can generate page-specific metadata dynamically:

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

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

  return {
    title: post.metaTitle,            // Keep under 60 characters
    description: post.metaDescription, // Keep under 155 characters
    keywords: post.tags,
    authors: [{ name: post.author }],

    // Canonical URL — prevents duplicate indexing
    alternates: {
      canonical: `/blog/${post.slug}`,
    },

    // Open Graph — displayed when shared on social media
    openGraph: {
      title: post.metaTitle,
      description: post.metaDescription,
      url: `https://example.com/blog/${post.slug}`,
      type: "article",
      images: [{
        url: "https://example.com/opengraph-image",
        width: 1200, height: 630,
        alt: post.title,
      }],
    },

    // Twitter Card
    twitter: {
      card: "summary_large_image",
      title: post.metaTitle,
      description: post.metaDescription,
    },
  };
}

Commonly Missed Items

  • Canonical URL: Without it, www and non-www or http and https versions are treated as separate pages. Always set alternates.canonical.
  • Title length: Titles over 60 characters get truncated in search results. Manage a separate metaTitle field.
  • Description: If empty, Google auto-extracts content — which may not represent your page well.

Open Graph Image Auto-Generation

Next.js generates OG images automatically when you create an opengraph-image.tsx file. No more manually designing images in Figma for every page:

// app/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default function Image() {
  return new ImageResponse(
    (
      <div style={{
        display: "flex",
        width: "100%",
        height: "100%",
        background: "linear-gradient(135deg, #1a1a2e, #16213e)",
        color: "white",
        alignItems: "center",
        justifyContent: "center",
        fontSize: 60,
        fontWeight: 700,
      }}>
        Your Site Name
      </div>
    ),
    { ...size }
  );
}

Place this in the app/ root for a site-wide default OG image. For per-post images, create app/blog/[slug]/opengraph-image.tsx that dynamically renders the post title.

Dynamic sitemap.xml Generation

The sitemap tells search engines which pages exist on your site. In Next.js, generate it dynamically:

// app/sitemap.xml/route.ts
import { getAllPosts } from "@/lib/blog";

export async function GET() {
  const posts = getAllPosts();
  const baseUrl = "https://example.com";

  const staticPages = [
    { url: "/", lastmod: new Date().toISOString(), priority: 1.0 },
    { url: "/blog", lastmod: new Date().toISOString(), priority: 0.8 },
    { url: "/services/web", priority: 0.8 },
  ];

  const blogPages = posts.map(post => ({
    url: `/blog/${post.slug}`,
    lastmod: post.date,
    priority: 0.7,
  }));

  const pages = [...staticPages, ...blogPages];

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${pages.map(p => `  <url>
    <loc>${baseUrl}${p.url}</loc>
    ${p.lastmod ? `<lastmod>${p.lastmod}</lastmod>` : ""}
    <priority>${p.priority || 0.5}</priority>
  </url>`).join("\n")}
</urlset>`;

  return new Response(xml, {
    headers: { "Content-Type": "application/xml" },
  });
}

Sitemap Pitfalls

  • Exclude noindex pages: Login, admin, and API routes should not appear in the sitemap.
  • Accurate lastmod: Use the actual modification date, not new Date() on every request.
  • Submit to Search Console: After creating the sitemap, submit it in Google Search Console so crawlers discover it.

robots.txt Configuration

The robots.txt file tells crawlers which paths they can and cannot access:

// app/robots.txt/route.ts
export function GET() {
  const content = `User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Disallow: /auth/

Sitemap: https://example.com/sitemap.xml`;

  return new Response(content, {
    headers: { "Content-Type": "text/plain" },
  });
}
PathSettingReason
/AllowPublic pages should be crawled
/adminDisallowAdmin panel should not be indexed
/api/DisallowAPI endpoints should not be indexed
/auth/DisallowLogin/signup pages add no SEO value

JSON-LD Structured Data

JSON-LD communicates page content to search engines in a structured format. Adding aBlogPosting schema to blog posts can trigger rich results in search — showing publish date, author, and description directly in the SERP:

// components/blog/BlogJsonLd.tsx
export function BlogJsonLd({ post }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.date,
    author: {
      "@type": "Person",
      name: post.author,
    },
    publisher: {
      "@type": "Organization",
      name: "Your Company",
      url: "https://example.com",
    },
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": `https://example.com/blog/${post.slug}`,
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}
Page TypeSchemaRich Result
Blog postBlogPostingPublish date, author displayed
Company pageOrganizationLogo, contact panel
FAQFAQPageExpandable FAQ accordion
Product/ServiceProduct/ServicePrice, rating displayed

IndexNow — Instant Indexing Notification

When you publish or update content, search engines normally wait until their next crawl to discover it. IndexNow notifies search engines immediately via a simple API call:

const response = await fetch(
  "https://api.indexnow.org/indexnow", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      host: "example.com",
      key: "your-indexnow-key",
      urlList: [
        "https://example.com/blog/new-post",
        "https://example.com/blog/updated-post",
      ],
    }),
  }
);
// Deploy key file at example.com/your-key.txt

Google does not support IndexNow — use Google Search Console's URL Inspection tool or resubmit your sitemap instead. However, Bing indexes pages within minutes via IndexNow, making it valuable for time-sensitive content.

SEO 100 Checklist

  • Set title, description, and canonical URL on every page
  • Add Open Graph and Twitter Card meta tags
  • Auto-generate OG images with opengraph-image.tsx
  • Include all public pages in sitemap.xml with accurate lastmod dates
  • Block admin, API, and auth paths in robots.txt
  • Add JSON-LD structured data (BlogPosting, Organization, etc.)
  • Notify search engines of new content via IndexNow
  • Register your site in Google Search Console and submit the sitemap

A perfect SEO score means your technical configuration is complete — it does not guarantee high rankings. Search ranking depends on content quality, backlinks, user experience, and many other factors. But without the technical foundation, even the best content will not reach its full potential in search results.