Skip to content
Innovialabs

Next.js SEO: Metadata, Sitemap and robots in the App Router

Practice4 min

In short

In the App Router SEO is configured in code — a metadata object or generateMetadata for titles, canonical and hreflang, sitemap.ts and robots.ts for crawling, opengraph-image for previews; the key is absolute URLs via metadataBase and a canonical of its own on every page.

How SEO works in the App Router

In the App Router you do not write <head> tags by hand. Next.js assembles them from several sources:

  • metadata — a static object in layout.tsx or page.tsx;
  • generateMetadata — a function for dynamic pages;
  • special files sitemap.ts, robots.ts, opengraph-image.tsx in the app folder.

Metadata is inherited: a page receives fields from every layout above it and overrides them with its own. That is convenient, but it is also the source of the mistakes described below.

Base metadata in the layout

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

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: { default: 'Company', template: '%s — Company' },
  description: 'A short site description',
}

metadataBase turns relative paths (/blog/post) into absolute URLs in canonical, hreflang and Open Graph tags. Without it, social preview links can break.

generateMetadata for dynamic pages

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Props = { params: Promise<{ slug: string }> }

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

  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: `/blog/${slug}`,
      languages: {
        ru: `/ru/blog/${slug}`,
        en: `/en/blog/${slug}`,
        'x-default': `/en/blog/${slug}`,
      },
    },
    openGraph: { type: 'article', title: post.title },
  }
}

In recent Next.js versions params is a Promise, so you need to await it. fetch calls with the same arguments in generateMetadata and in the page itself are deduplicated, so there is no extra load. For an unknown slug call notFound() so the server returns a 404 rather than an empty page with status 200.

sitemap.ts and robots.ts

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()
  return posts.map((p) => ({
    url: `https://example.com/en/blog/${p.slug}`,
    lastModified: p.updatedAt,
    alternates: {
      languages: { ru: `https://example.com/ru/blog/${p.slug}` },
    },
  }))
}
// app/robots.ts
import type { MetadataRoute } from 'next'

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

Only include canonical URLs that return 200 in the sitemap. If you have more URLs than one file allows (50,000), split the sitemap with generateSitemaps.

Open Graph images

An opengraph-image.tsx (or .png/.jpg) file next to a page adds og:image automatically. With ImageResponse from next/og you can generate the image from the article title. The recommended size is 1200×630.

Typical mistakes that leave pages unindexed

  • Canonical in the root layout. alternates.canonical: '/' is inherited by every page, so they all point to the home page. Set canonical per page.
  • No metadataBase — relative canonical and og:image URLs resolve incorrectly.
  • robots: { index: false } left over from staging or set in a shared layout.
  • Non-reciprocal hreflang: the Russian version links to the English one, but not the other way round. Every version must list all the others and itself.
  • Redirects, 404s and non-canonical URLs in the sitemap — the bot wastes budget and trusts the sitemap less.
  • Empty HTML. If content loads only on the client via 'use client' and useEffect, the bot may see an empty page. Render the main text on the server.
  • Soft 404s: a "not found" page returns 200 instead of using notFound().
  • Trailing slash inconsistency: both /blog and /blog/ resolve without a redirect.
  • Thin and duplicate pages: Google often crawls thousands of near-identical pages but does not index them.

How to check the result

  1. View the page source and find <title>, canonical, hreflang and og:image.
  2. Open /sitemap.xml and /robots.txt in the browser.
  3. Use URL Inspection in Search Console: it shows which canonical Google chose and whether it sees the content.

FAQ

Shared fields (metadataBase, the title template) belong in the layout, while canonical belongs in each page or its generateMetadata. Otherwise canonical is inherited and ends up identical across many pages.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.