Skip to content
Innovialabs

SEO in Next.js: Metadata, Sitemap, robots.txt and OG Images

Practice4 min

In short

In the App Router, SEO is set with the metadata export or generateMetadata, special files sitemap.ts, robots.ts and opengraph-image.tsx, plus a JSON-LD script; for multilingual routes every page needs a canonical URL and hreflang alternates.

The short answer

In a Next.js App Router project you do not need an SEO plugin. Everything is built in:

  • metadata or generateMetadata in layout.tsx and page.tsx for title, description, canonical, hreflang and Open Graph;
  • app/sitemap.ts and app/robots.ts to generate sitemap.xml and robots.txt;
  • opengraph-image.tsx to render share images from code;
  • a <script type="application/ld+json"> tag for structured data.

The examples below follow the App Router conventions; check the official docs for your exact Next.js version, because details such as params being a Promise have changed over time.

Base settings in the root layout

Set metadataBase once so relative URLs in canonical and Open Graph become absolute:

// app/[lang]/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: { default: 'Studio', template: '%s | Studio' },
}

The template adds the site name to every page title automatically.

Dynamic metadata, canonical and hreflang

For pages built from data, use generateMetadata. In a multilingual site each language version needs its own canonical and a full set of hreflang alternates, including x-default:

// app/[lang]/blog/[slug]/page.tsx
type Props = { params: Promise<{ lang: string; slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = await params
  const post = await getPost(slug, lang)
  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: `/${lang}/blog/${slug}`,
      languages: {
        ru: `/ru/blog/${slug}`,
        en: `/en/blog/${slug}`,
        uz: `/uz/blog/${slug}`,
        'x-default': `/ru/blog/${slug}`,
      },
    },
    openGraph: { type: 'article', title: post.title, description: post.description },
  }
}

Key rules:

  • canonical points to the page itself, not to the main language version;
  • every language version lists all versions, including itself;
  • only list translations that actually exist.

Dynamic sitemap

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

const base = 'https://example.com'
const langs = ['ru', 'en', 'uz']

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()
  return posts.flatMap((p) =>
    langs.map((lang) => ({
      url: `${base}/${lang}/blog/${p.slug}`,
      lastModified: p.updatedAt,
      alternates: {
        languages: Object.fromEntries(langs.map((l) => [l, `${base}/${l}/blog/${p.slug}`])),
      },
    })),
  )
}

A single sitemap file has a limit of 50,000 URLs. For larger sites, Next.js offers generateSitemaps to split it into several files. Use a real lastModified date, not the build time, otherwise search engines learn to ignore it.

robots.txt

// 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',
  }
}

Remember: disallow blocks crawling, not indexing. To keep a page out of search results, use robots: { index: false } in its metadata and keep it crawlable so the search engine can see that tag.

Generated Open Graph images

A file named opengraph-image.tsx next to a page produces its share image. ImageResponse renders JSX into a PNG:

// app/[lang]/blog/[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: Promise<{ lang: string; slug: string }> }) {
  const { lang, slug } = await params
  const post = await getPost(slug, lang)
  return new ImageResponse(
    <div style={{ display: 'flex', width: '100%', height: '100%', padding: 80, fontSize: 64, background: '#120B24', color: '#fff' }}>
      {post.title}
    </div>,
    size,
  )
}

Next.js adds the og:image tags automatically. Only flexbox layout and a subset of CSS are supported; for Cyrillic or Uzbek characters, load a font that includes them.

JSON-LD structured data

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: post.title,
  datePublished: post.date,
  inLanguage: lang,
}

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>

Escaping < protects against injecting HTML through content. Validate the result with Google's Rich Results Test.

Checklist before release

  • Unique title and description on every page.
  • Canonical and hreflang on all language versions.
  • All public pages in the sitemap, with no redirects or 404s inside it.
  • Internal links between related pages, so crawlers can reach every URL without the sitemap.
  • Sitemap submitted in Google Search Console and Yandex Webmaster.

FAQ

The sitemap only helps discovery; indexing depends on content quality and uniqueness, internal links and signals of importance. Check for duplicates, thin pages and wrong canonicals, and link to new pages from existing ones.

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.