MW

How-To · Frameworks

Next.js SEO — Complete Best Practices Guide (2026 App Router)

App Router SEO is dramatically simpler than the Pages Router era — one Metadata API, file-based sitemap and robots, dynamic OG images via @vercel/og. Here's every pattern you need.

Jan 24, 2025 9 min read Any platform intermediate
Advertisement

Next.js App Router replaces every SEO pattern from the Pages Router era (<Head>, getStaticProps, custom _document.js) with a unified Metadata API + file conventions. Here’s the complete setup.

my-next-app/
app/
layout.tsx global metadata + JSON-LD
sitemap.ts sitemap.xml route
robots.ts robots.txt route
blog/
[slug]/
page.tsx generateMetadata per post
opengraph-image.tsx OG image per post
next.config.js

Metadata API — per-page

Every page.tsx and layout.tsx can export metadata:

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

export const metadata: Metadata = {
  title: 'About — Mahesh Waghmare',
  description: 'WordPress + Astro developer running a 46-site digital empire solo.',
  openGraph: {
    title: 'About — Mahesh Waghmare',
    description: 'WordPress + Astro developer running a 46-site digital empire solo.',
    url: 'https://maheshwaghmare.com/about',
    siteName: 'Mahesh Waghmare',
    images: [{ url: '/og/about.png', width: 1200, height: 630 }],
    type: 'profile',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'About — Mahesh Waghmare',
    description: '46 sites, solo, evenings.',
    images: ['/og/about.png'],
  },
};

export default function AboutPage() {
  return <main>...</main>;
}

generateMetadata for dynamic routes

For app/blog/[slug]/page.tsx:

import type { Metadata } from 'next';

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

  return {
    title: `${post.title} — Mahesh Waghmare`,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      url: `https://maheshwaghmare.com/blog/${post.slug}`,
      images: [`/og/blog/${post.slug}.png`],
      type: 'article',
      publishedTime: post.publishedAt,
      authors: ['Mahesh Waghmare'],
    },
  };
}

Root layout metadata (site-wide defaults)

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://maheshwaghmare.com'),
  title: {
    template: '%s — Mahesh Waghmare',
    default: 'Mahesh Waghmare',
  },
  description: 'WordPress + Astro developer running 46 sites solo.',
  alternates: { canonical: '/' },
  icons: { icon: '/favicon.svg' },
};

Setting title.template makes every per-page title automatically suffix with ” — Mahesh Waghmare”. metadataBase lets you write relative URLs everywhere else.

Sitemap — app/sitemap.ts

File-based — no next-sitemap library needed:

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';

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

  return [
    {
      url: 'https://maheshwaghmare.com',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: 'https://maheshwaghmare.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    ...posts.map((post) => ({
      url: `https://maheshwaghmare.com/blog/${post.slug}`,
      lastModified: new Date(post.updatedAt),
      changeFrequency: 'weekly' as const,
      priority: 0.6,
    })),
  ];
}

Next.js serves this at /sitemap.xml automatically.

Robots — app/robots.ts

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

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
      { userAgent: 'GPTBot', disallow: '/' },  // opt out of OpenAI training
    ],
    sitemap: 'https://maheshwaghmare.com/sitemap.xml',
    host: 'https://maheshwaghmare.com',
  };
}

Dynamic OG images

Next.js bundles @vercel/og for runtime OG image generation:

// app/blog/[slug]/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 async function OG({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);

  return new ImageResponse(
    (
      <div style={{
        background: 'linear-gradient(135deg, #f97316, #fbbf24)',
        width: '100%', height: '100%',
        display: 'flex', flexDirection: 'column',
        justifyContent: 'center', padding: '80px',
      }}>
        <div style={{ fontSize: 64, fontWeight: 700, color: '#fff' }}>
          {post.title}
        </div>
        <div style={{ fontSize: 32, color: '#fff', opacity: 0.9, marginTop: 20 }}>
          maheshwaghmare.com
        </div>
      </div>
    ),
    { ...size }
  );
}

The image is generated on first request, cached at the edge, and served from /blog/<slug>/opengraph-image. Reference it in metadata:

export const metadata = {
  openGraph: { images: ['/blog/my-post/opengraph-image'] },
};

Structured data (JSON-LD)

Inject <script type="application/ld+json"> per-page or in layout:

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

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: 'Mahesh Waghmare',
      url: 'https://maheshwaghmare.com/about',
    },
    image: `https://maheshwaghmare.com/og/blog/${post.slug}.png`,
  };

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

Test with Google’s Rich Results tool after deploy.

Core Web Vitals

The other half of SEO. Next.js gives you tools but you have to use them:

Images — next/image

import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero illustration"
  width={1200}
  height={630}
  priority  // LCP image — preload
/>

priority on the LCP image hints to the browser to preload — measurable difference on Largest Contentful Paint.

Fonts — next/font

// app/layout.tsx
import { Inter } from 'next/font/google';

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

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

Self-hosts fonts at build, eliminates FOIT, no CDN render-blocking.

Bundle analysis

Terminal — analyze bundle
$ ANALYZE=true npm run build
(opens an interactive treemap of your bundle — find what to lazy-load)

(Requires @next/bundle-analyzer in next.config.js.)

Canonical URLs

Per-page:

export const metadata = {
  alternates: { canonical: 'https://maheshwaghmare.com/blog/my-post' },
};

In app/layout.tsx with metadataBase, you can use relative paths:

export const metadata = {
  metadataBase: new URL('https://maheshwaghmare.com'),
  alternates: { canonical: '/' },  // resolves to https://maheshwaghmare.com/
};

i18n / multilingual

If you serve the same content in multiple languages:

export const metadata = {
  alternates: {
    canonical: '/blog/my-post',
    languages: {
      'en-US': '/en/blog/my-post',
      'mr-IN': '/mr/blog/my-post',
    },
  },
};

Verification & monitoring

After deploy:

  1. Google Search Console — submit sitemap.xml, verify indexation
  2. Rich Results Test — validate your JSON-LD generates rich snippets
  3. PageSpeed Insights — Core Web Vitals scores per page
  4. Lighthouse CI — automate the audit in your pipeline

Common pitfalls

SymptomCauseFix
Title appears twice in tabLayout + Page both set non-template titleUse title.template in layout
OG image not appearing in sharesCache — Twitter / FB cache OG images aggressivelyForce re-scrape via FB Sharing Debugger
JSON-LD validates but no rich resultsSchema mismatch, content gap, or just unindexed yetWait 7-14 days, then check Search Console “Enhancements”
Bundle too bigToo many 'use client' componentsAudit with bundle analyzer, push state down
Search Console shows 404sSitemap includes URLs that 404Filter your sitemap.ts source data

Conclusion

Next.js App Router SEO boils down to 5 files:

  1. app/layout.tsx — root metadata defaults
  2. page.tsx — per-page metadata or generateMetadata
  3. app/sitemap.ts — your URL list
  4. app/robots.ts — crawl rules
  5. opengraph-image.tsx — dynamic social previews

Get these right + nail Core Web Vitals via next/image and next/font, and you’ve covered 90% of technical SEO without external tools.

Next.js SEO Metadata Web Development Performance
Advertisement

Get weekly notes in your inbox

Practical tips, tutorials and resources. No spam.