VG-Solutions
seo

Next.js SEO: A Practical Guide for the App Router

What Next.js SEO Actually Means in the App Router

Next.js SEO in the App Router comes down to five concrete jobs: generating correct metadata per page, telling search engines which URL is canonical, exposing a sitemap and robots file so crawlers can find and prioritize pages, adding structured data where it's genuinely useful, and picking a rendering strategy that produces real HTML for crawlers to read. None of this is exotic — it's mostly about using the primitives the App Router already ships with, correctly and consistently.

This guide walks through each piece with working code, in the order you'll actually hit them while building a Next.js site.

generateMetadata: Titles, Descriptions, and Open Graph

In the App Router, next/head is gone. Metadata lives in two places: a static metadata export, or an async generateMetadata function when the values depend on dynamic data.

Static example, for a page whose title never changes:

export const metadata = {
 title: "Pricing | Acme",
 description: "Compare Acme plans and pick the right tier for your team.",
};

Dynamic example, for something like a blog post or product page:

export async function generateMetadata({ params }) {
 const post = await getPost(params.slug);

 return {
 title: `${post.title} | Acme Blog`,
 description: post.excerpt,
 alternates: { canonical: `https://example.com/blog/${params.slug}` },
 openGraph: {
 title: post.title,
 description: post.excerpt,
 images: [post.coverImage],
 },
 };
}

A few practical rules that matter more than people expect:

  • Every indexable page needs a unique title and description. Duplicate titles across pages dilute relevance signals and make it harder for search engines to pick which page to show for a query.
  • Keep titles descriptive and specific rather than generic. "Acme" alone tells a crawler nothing about the page's topic.
  • Metadata set in a layout applies to nested pages unless overridden, so put shared defaults (site name suffix, default Open Graph image) in the root layout and override per page.

Canonical URLs: Avoiding Duplicate Content

Canonical tags tell search engines which URL is the "real" version when the same content is reachable through multiple paths — trailing slashes, query parameters, tracking params, or paginated variants.

export const metadata = {
 alternates: {
 canonical: "https://example.com/blog/nextjs-seo",
 },
};

Set canonicals explicitly wherever a URL could plausibly have variants: filtered product listings, paginated category pages, and any route accessible via multiple query-string combinations. Without a canonical, search engines guess, and the guess isn't always the page you'd choose.

For pagination specifically, each page in the series should canonicalize to itself (not always to page 1) unless you're deliberately consolidating a "view all" version.

sitemap.ts and robots.ts: Native Files for Crawlability

The App Router supports sitemap.ts and robots.ts as first-class route files — no manual XML, no third-party package required.

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,
 }));

 return [
 { url: "https://example.com", lastModified: new Date() },
 { url: "https://example.com/pricing", lastModified: new Date() },
 ...postEntries,
 ];
}

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",
 };
}

Keep the sitemap limited to canonical, indexable URLs — don't list pages you've also marked noindex, and don't include admin routes, internal search results, or duplicate parameterized URLs. A sitemap full of low-value or blocked URLs makes crawlers spend less attention on the pages that matter.

For sites with thousands of URLs, generate the sitemap dynamically from your data source (CMS, database) rather than hand-maintaining a list, and split into multiple sitemaps if you approach practical size limits.

Structured Data (JSON-LD) in the App Router

Structured data doesn't change rankings directly, but it helps search engines and AI systems understand what a page represents — which matters for how content gets summarized, cited, or shown in rich results. Add JSON-LD by rendering a script tag directly in a Server Component:

export default async function BlogPostPage({ params }) {
 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 the page */}
 </article>
 );
}

Only mark up what's actually on the page. Schema for reviews, ratings, or FAQs that don't visibly appear in the content is a mismatch between structured data and rendered content, which is the opposite of what structured data is for. Reasonable candidates: Article/BlogPosting for posts, Product for e-commerce pages, FAQPage for genuine FAQ sections, BreadcrumbList for navigation, and Organization for site-wide identity in the root layout.

If you're also thinking about how your content gets picked up by AI answer engines rather than just classic search, structured data and clean semantic HTML are part of the same foundation — you can check how a specific URL currently reads to AI systems with the free GEO checker tool.

Rendering Choices: SSG, SSR, and Why It Affects Crawlability

Search engine crawlers generally handle modern JavaScript rendering better than they used to, but relying on client-side rendering for your primary content is still a risk you don't need to take. The App Router gives you control per route:

  • Static rendering (default) — pages without dynamic data are rendered at build time into plain HTML. This is the safest and fastest option for crawlability: content is present in the initial response.
  • Server-Side Rendering — use fetch with { cache: "no-store" } or dynamic functions to render per-request when content changes frequently (inventory, prices, personalized data that still needs to be crawlable in a generic form).
  • Client-side data fetching — fine for interactive widgets, dashboards, or anything behind a login that shouldn't be indexed anyway. Avoid it for the primary text content of a page you want ranked, since it means the crawler's initial HTML is empty or incomplete until JavaScript executes.

A practical rule: if a piece of text is important enough that you want it to show up in search results, it should be present in the server-rendered HTML, not injected client-side after mount.

Images, Core Web Vitals, and Metadata Together

The next/image component handles responsive sizing and lazy loading, but for SEO specifically, make sure images used in Open Graph tags are absolute URLs and reasonably sized — most platforms have expected aspect ratios for social previews. Also make sure your alt text on in-content images is descriptive; it's a minor but real relevance signal, and it's also accessibility best practice, so there's no tradeoff in getting it right.

Putting It Together: A Launch Checklist

  • Unique title and description via metadata or generateMetadata on every indexable page
  • Explicit canonical on any URL with possible duplicate variants
  • sitemap.ts generated from your real data source, limited to indexable canonical URLs
  • robots.ts disallowing admin/internal routes and pointing to the sitemap
  • JSON-LD only where it matches visible content
  • Primary content server-rendered (SSG or SSR), not client-fetched-only
  • Descriptive alt text and correctly sized Open Graph images

If your team is evaluating a broader SEO strategy beyond the Next.js implementation details — content structure, internal linking, technical audits — that's a good next step to pair with the technical work above; see our SEO services. And if the site itself needs to be built or rebuilt on the App Router with these patterns in place from day one, that falls under our web app development and website development work.

None of this requires guessing. The App Router gives you the file conventions; the job is applying them consistently across every route that matters.

FAQ

No. The App Router doesn't hurt SEO by itself — it changes how you write metadata (generateMetadata instead of next/head) and gives you native files like sitemap.ts and robots.ts. SEO outcomes still depend on rendering strategy, content quality, and correct implementation, not the router version.

Use the static metadata export when title/description don't depend on data. Use the async generateMetadata function when metadata depends on dynamic content, like a product name or blog post title fetched from a database or CMS.

No. Add JSON-LD where it maps to a real schema type your content matches — articles, products, FAQs, breadcrumbs, or organization info. Adding schema that doesn't reflect the page's actual content risks confusing search engines rather than helping them.

Not required, but crawlers need to see meaningful HTML without executing heavy client-side logic. SSG and SSR both produce server-rendered HTML at request or build time, which is safer for SEO than a page that renders its main content only after client-side JavaScript runs.