iF
iFace

Next.js SEO Checklist: Metadata, Sitemap, JSON-LD

App Router metadata, generateMetadata, sitemap.ts, robots.ts and JSON-LD — plus the rendering traps that silently keep Next.js pages out of the index.

By iFace ·

Next.js gives you almost everything SEO needs out of the box — and almost none of it is on by default. This is the App Router checklist: what to add, where it goes, and the mistakes that quietly cost you indexing.

All examples use the App Router (app/), Next.js 13.2+.


1. Set metadataBase once

Open Graph and canonical URLs must be absolute. metadataBase lets you write relative paths everywhere else and have Next.js resolve them:

// app/layout.tsx
export const metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "iFace — Frontend Blog for Beginners",
    template: "%s — iFace",
  },
  description:
    "Learn HTML, CSS and JavaScript and prepare for frontend technical interviews.",
};

The template is the useful part: any child page that sets title: "Core Web Vitals" renders as Core Web Vitals — iFace, and you never repeat the brand suffix by hand.

Without metadataBase, Next.js falls back to localhost:3000 in development and logs a warning — and your OG images break in production.


2. Static metadata for static pages

// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "Who writes iFace and why.",
  alternates: { canonical: "/about" },
  openGraph: {
    title: "About iFace",
    description: "Who writes iFace and why.",
    type: "website",
  },
};

alternates.canonical is the piece most projects forget. Resolved against metadataBase, "/about" becomes https://example.com/about.


3. generateMetadata for dynamic routes

For anything with a [slug], metadata has to be derived from the content:

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

interface Props {
  params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = getPostContent(slug);

  if (!post) {
    return { title: "Post not found" };
  }

  return {
    title: post.data.title,
    description: post.data.description,
    alternates: { canonical: `/post/${slug}` },
    openGraph: {
      title: post.data.title,
      description: post.data.description,
      type: "article",
      url: `/post/${slug}`,
    },
  };
}

Two things to notice:

  • In Next.js 15 params is a Promise and must be awaited. Forgetting this is the most common upgrade bug.
  • Don't worry about the duplicate fetch. Next.js dedupes identical requests inside a render pass, so calling the same loader in generateMetadata and in the page component costs you one fetch, not two.

4. Pre-render your routes

export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Every returned param becomes a statically generated HTML file at build time. That is the fastest possible response and guarantees the crawler receives complete markup on the first pass — no JavaScript execution required.


5. sitemap.ts

Next.js generates /sitemap.xml from a single file — no library, no build script:

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

const BASE_URL = "https://example.com";

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = getAllPosts().map((post) => ({
    url: `${BASE_URL}/post/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "monthly" as const,
    priority: 0.7,
  }));

  return [
    {
      url: BASE_URL,
      lastModified: new Date(),
      changeFrequency: "weekly",
      priority: 1,
    },
    ...posts,
  ];
}

Keep it honest: lastModified should reflect a real content change. A sitemap that claims every page changed today teaches search engines to ignore the field.


6. robots.ts

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

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

If you need a page crawled but not indexed, that is not a robots.txt job — blocking a URL prevents the crawler from ever seeing the noindex tag. Use metadata instead:

export const metadata = {
  robots: { index: false, follow: true },
};

7. OG images

Drop an opengraph-image.tsx next to a route and Next.js generates the image at the edge:

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

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

export default async function Image({ params }: { params: { slug: string } }) {
  const post = getPostContent(params.slug);

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          background: "#1a1a2e",
          color: "white",
          fontSize: 64,
          padding: 80,
          textAlign: "center",
        }}
      >
        {post.data.title}
      </div>
    ),
    size
  );
}

A static opengraph-image.png in the same folder works too, and costs nothing at runtime.


8. JSON-LD

There is no metadata API for structured data — you render the script tag yourself, in a Server Component:

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = getPostContent(slug);

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

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {/* ...content... */}
    </article>
  );
}

JSON.stringify on an object you control is the sane way to do this. Never interpolate raw user input into that string.


9. Images and fonts

import Image from "next/image";

<Image
  src="/hero.png"
  alt="Lighthouse report showing a perfect performance score"
  width={1200}
  height={630}
  priority       // above the fold: preloads, skips lazy loading
/>

next/image handles format negotiation, responsive srcset and the intrinsic sizing that prevents layout shift. The one flag you have to set yourself is priority on your LCP image.

For fonts, next/font self-hosts the files and inlines the CSS, which removes a render-blocking request to Google Fonts and eliminates the font-swap layout shift:

import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"], display: "swap" });

10. Traps that silently break indexing

  • "use client" at the top of a page file. Metadata exports are ignored in Client Components — the page ships with no title.
  • Content behind useEffect. It will not be in the initial HTML. Fetch in the Server Component instead.
  • Trailing slash inconsistency. /post/x and /post/x/ serving the same content without a canonical is duplicate content. Pick one via trailingSlash in next.config.js.
  • Redirect chains. http → https → www → path costs crawl budget. Collapse them to one hop.
  • A missing 404. A route that returns 200 with an empty page gets indexed as thin content. Call notFound().

Verifying it

npm run build && npm start
curl -s http://localhost:3000/post/your-slug | grep -i "<title>"
curl -s http://localhost:3000/sitemap.xml | head
curl -s http://localhost:3000/robots.txt

If the title is there in raw curl output, the crawler sees it too.

After deploying, submit the sitemap in Google Search Console and use URL Inspection to see the rendered HTML Google actually stored. For an ongoing view — crawling every route, catching the page where someone forgot generateMetadata, comparing your coverage against competing sites — an AI SEO tool like RankBuddy automates that loop: it audits the site, connects to Search Console, flags missing schema and metadata, and re-runs on a schedule so a regression shows up in days instead of at the next quarterly review.


The checklist

  • metadataBase + title template in the root layout
  • title and description on every route
  • alternates.canonical on every route
  • generateMetadata awaits params on dynamic routes
  • generateStaticParams for known slugs
  • app/sitemap.ts with honest lastModified
  • app/robots.ts pointing at the sitemap
  • OG image, 1200×630
  • JSON-LD matching the page type
  • priority on the LCP image, next/font for fonts
  • No "use client" on page files that need metadata
  • notFound() for missing content

Related reading: SEO for frontend developers for the framework-agnostic fundamentals, and Core Web Vitals explained for the performance half.