AverageDevs
SEONext.js

Next.js SEO Best Practices (App Router, 2025 Edition)

A practical, production‑ready checklist for SEO with Next.js App Router - metadata, canonical URLs, sitemaps, robots, Open Graph images, structured data, i18n, performance, and common pitfalls.

Next.js SEO Best Practices (App Router, 2025 Edition)

Shipping SEO with Next.js isn’t about sprinkling meta tags; it’s about giving crawlers clean signals while keeping performance and DX high. This guide focuses on the App Router (Next.js 13+) and covers the must‑have building blocks, examples, and a blunt checklist you can run before every launch.

TL;DR

  • Use the Metadata API (metadata, generateMetadata) for canonical, OpenGraph, and Twitter cards.
  • Ship robots.ts and sitemap.ts; verify they reflect your public vs private routes.
  • Generate dynamic OG images via opengraph-image.tsx for higher CTR.
  • Add structured data (JSON‑LD) for articles, breadcrumbs, products; validate it.
  • Handle i18n/alternates with metadata.alternates, not manual <link> tags.
  • Noindex low‑value pages (search, preview, dashboard) and fix canonicalization.
  • Hit Core Web Vitals: image optimization, font loading, code‑splitting, caching.

1) Metadata API: Single Source of Truth

Define site‑wide defaults in app/metadata.ts and override per page or via generateMetadata.

// app/metadata.ts
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://www.averagedevs.com"),
  title: {
    default: "AverageDevs  -  Practical Engineering Guides",
    template: "%s  -  AverageDevs",
  },
  description: "Actionable engineering guides for modern web apps.",
  alternates: {
    canonical: "/",
  },
  openGraph: {
    type: "website",
    siteName: "AverageDevs",
    url: "/",
    images: [
      { url: "/opengraph-image.png", width: 1200, height: 630, alt: "AverageDevs" },
    ],
  },
  twitter: {
    card: "summary_large_image",
    creator: "@averagedevs",
  },
};

For dynamic pages (e.g., blog posts), compute metadata from content:

// app/blog/[slug]/page.tsx (excerpt)
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/site"; // example helper

export const generateMetadata = async ({ params }: { params: { slug: string } }): Promise<Metadata> => {
  const post = await getPostBySlug(params.slug);
  if (!post) return { title: "Not found", robots: { index: false, follow: false } };

  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: `/blog/${post.slug}`,
    },
    openGraph: {
      type: "article",
      url: `/blog/${post.slug}`,
      title: post.title,
      description: post.description,
      images: [{ url: post.thumbnail, width: 1200, height: 630 }],
    },
    twitter: { card: "summary_large_image" },
  };
};

2) Robots and Sitemap You Actually Maintain

Keep these files in app/ so they are versioned, typed, and tested.

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

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow: ["/admin", "/api", "/preview"] },
    ],
    sitemap: "https://www.averagedevs.com/sitemap.xml",
  };
}
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { allPosts } from "@/lib/site"; // example helper

export default function sitemap(): MetadataRoute.Sitemap {
  const base = "https://www.averagedevs.com";
  const blogUrls = allPosts().map((p) => ({
    url: `${base}/blog/${p.slug}`,
    lastModified: p.updatedAt ?? p.date,
    changeFrequency: "weekly",
    priority: 0.7,
  }));

  return [
    { url: `${base}/`, changeFrequency: "weekly", priority: 0.8 },
    ...blogUrls,
  ];
}

3) Dynamic Open Graph Images (Boost CTR)

Generate per‑page OG images with text/author overlays to improve social click‑throughs.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/site";

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

export default async function OgImage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  if (!post) return new ImageResponse(<div>Not found</div>, { ...size });

  return new ImageResponse(
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        padding: 64,
        background: "#0b0b0b",
        color: "white",
        fontSize: 64,
      }}
    >
      <div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.2 }}>{post.title}</div>
      <div style={{ marginTop: 24, fontSize: 32, opacity: 0.7 }}>AverageDevs</div>
    </div>,
    { ...size }
  );
}

4) Structured Data (JSON‑LD)

Use JSON‑LD for Articles, Breadcrumbs, Products, Events. Validate with Google’s Rich Results Test.

// app/blog/[slug]/page.tsx (excerpt)
const ArticleJsonLd = ({ post }: { post: any }) => (
  <script
    type="application/ld+json"
    dangerouslySetInnerHTML={{
      __html: JSON.stringify({
        "@context": "https://schema.org",
        "@type": "Article",
        headline: post.title,
        description: post.description,
        image: [post.thumbnail],
        datePublished: post.date,
        dateModified: post.updatedAt ?? post.date,
        author: [{ "@type": "Person", name: post.author || "AverageDevs" }],
      }),
    }}
  />
);

Breadcrumbs example:

const BreadcrumbJsonLd = ({ slug, title }: { slug: string; title: string }) => (
  <script
    type="application/ld+json"
    dangerouslySetInnerHTML={{
      __html: JSON.stringify({
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        itemListElement: [
          { "@type": "ListItem", position: 1, name: "Blog", item: "https://www.averagedevs.com/blog" },
          { "@type": "ListItem", position: 2, name: title, item: `https://www.averagedevs.com/blog/${slug}` },
        ],
      }),
    }}
  />
);

5) Canonicalization and Noindex Rules

  • Set alternates.canonical for each canonical URL.
  • Paginated, filter, and search pages: prefer noindex, follow.
  • Draft/preview/admin areas: noindex and block in robots.ts.
  • Avoid duplicate content from trailing slashes or query param variants.
// app/search/page.tsx
export const metadata = {
  robots: { index: false, follow: true },
  alternates: { canonical: "/search" },
} as const;

6) i18n and Alternates (hreflang)

Use metadata.alternates.languages for localized routes.

// app/[locale]/layout.tsx (excerpt)
import type { Metadata } from "next";

export const metadata: Metadata = {
  alternates: {
    canonical: "/",
    languages: {
      "en-US": "/en",
      "de-DE": "/de",
    },
  },
};

7) Performance Signals That Matter for SEO

  • Images: use next/image with correct sizes and eager‑load only LCP.
  • Fonts: use next/font; avoid layout shift; self‑host; display: swap.
  • Script strategy: beforeInteractive only when essential; otherwise afterInteractive.
  • Code splitting: next/dynamic for below‑the‑fold; SSR where beneficial.
  • Caching: leverage ISR/Route Segment Config; set long cache for static assets.
  • Vitals: monitor LCP, CLS, INP; fix slow pages first - Google uses page‑level signals.

Implement Consent Mode v2 if serving EEA/UK. Load analytics/ad scripts respecting consent and avoid polluting crawlable HTML with blocked widgets.

9) Pre‑Launch Checklist

  • Home, key category pages, and every article have unique titles/descriptions.
  • Canonical URLs set and correct; no accidental self‑canonical to wrong paths.
  • robots.ts allows public pages; blocks admin/preview.
  • sitemap.ts lists all public content and updates lastModified on change.
  • OpenGraph/Twitter cards render correctly and use final URLs.
  • JSON‑LD validates for articles and breadcrumbs.
  • Core Web Vitals green on mobile; images sized; fonts optimized.
  • 404/500 pages exist and are fast; no broken links.

10) Common Pitfalls

  • Mixing old Head API with Metadata API, causing duplicate tags.
  • Forgetting metadataBase, leading to relative OG URLs in social previews.
  • Returning stale sitemap that omits dynamic content or wrong lastModified.
  • Rendering ads/embeds server‑side that bloat HTML and hurt LCP.
  • No noindex on search/filtered pages, causing index bloat and cannibalization.

Final Thoughts

Great SEO in Next.js is mostly about good defaults, consistent metadata, and fast pages. Ship the foundations once, automate where possible (sitemap/OG), and treat each new route as a small SEO contract: correct canonical, descriptive metadata, structured data if relevant, and no regressions to performance.