SEO in Next.js: Metadata, Sitemap, robots.txt and OG Images
Practice4 minInnovialabs editorial team
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.tsxandpage.tsxfor title, description, canonical, hreflang and Open Graph; - app/sitemap.ts and app/robots.ts to generate
sitemap.xmlandrobots.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.