Next.js SEO Metadata Tutorial: generateMetadata to JSON-LD
A Next.js SEO metadata tutorial from a live bilingual site: generateMetadata, canonical and hreflang, Open Graph, sitemap.ts, robots.ts and JSON-LD.
By Matthews WongReviewed on
Key takeaway
SEO metadata in Next.js is the set of tags and files that tell crawlers what a page is: the title and description, a canonical URL, hreflang alternates, Open Graph and Twitter cards, a sitemap, robots rules and JSON-LD. In the App Router you declare them from server code, so they land in the HTML the crawler first receives.
You will be able to
- Write generateMetadata with the right title, canonical and language alternates.
- Generate a sitemap and robots file from code, and know what Google ignores.
- Avoid the title template and openGraph merge traps that fail silently.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
What a crawler reads from one bilingual page
A crawler reads robots.txt and the sitemap, requests a page, and gets head tags from generateMetadata plus a JSON-LD script rendered by a server component, all in one HTML response.
Keys: ← → step · Home / End · K play or pause
Two files tell a crawler where to go, one function writes the head tags, and a server component writes the JSON-LD. All four run on the server.
The crawler reads robots.txt, generated by robots.ts. It states which paths may be crawled and where the sitemap lives. It controls crawling, not indexing.
export default function robots(): MetadataRoute.RobotsPredict the next step A path is disallowed in robots.txt but another site links to it. Can it still be indexed? (No, disallow removes it · Yes, if linked from elsewhere)sitemap.ts returns one entry per URL, with its other-language versions under alternates.languages. The crawler learns that /en/ and /id/ belong together.
alternates.languages = buildLanguageAlternates(path)The crawler requests one page. Next.js resolves generateMetadata for the route before the page is sent, so the tags are part of that first HTML.
export async function generateMetadata(props)Predict the next step On the /id/ page, which URL should the canonical tag name? (The /en/ page, its original · Its own /id/ URL)The result is a title, description, a canonical pointing at this same URL, an hreflang link for each language plus x-default, and the Open Graph and Twitter tags.
alternates.canonical = canonical alternates.languages = ...The page's server component renders a script tag of type application/ld+json from plain data. It sits in the same HTML, with no client JavaScript needed to read it.
script type=application/ld+json
How does generateMetadata work in the Next.js App Router?
You describe a page's head from a layout.tsx or page.tsx in one of two ways: export a constant metadata object when the values are fixed, or export an async generateMetadata function when they depend on route params or data. You cannot export both from the same segment, and both work only in Server Components, because the metadata must be resolved on the server before the page is sent. If a client component needs hooks, keep the page a Server Component and move the interactive part into its own file.
Read more (2)Show less
When the page can be prerendered and generateMetadata adds no dynamic behaviour, the tags are in the initial HTML. Otherwise Next.js can stream them after the first UI; for bots that cannot run JavaScript it keeps blocking the render, so the tags stay in the head. Any fetch you make inside generateMetadata is memoised across generateMetadata, layouts and pages, so asking for the same data twice costs one request.
Metadata merges from the root layout down to the page, and the merge is shallow. A nested field such as openGraph defined in a later segment replaces the whole openGraph object from an earlier one, rather than adding to it. That is why a route that sets its own openGraph has to repeat the title, description and image it still wants.
What are canonical URLs and hreflang alternates for?
A canonical link names the preferred URL for a page. Google treats a rel=canonical element as a strong signal and a sitemap entry as a weak one, recommends absolute URLs, and suggests a self-referential canonical on the canonical page itself. That is the rule behind a bilingual site's setup: each language version names itself as canonical, instead of pointing at the other language.
Read more (2)Show less
hreflang links connect the language versions. Google's rules are strict: the annotations must be bidirectional, so if two pages do not point at each other the tags are ignored; each version must list itself as well as the others; and URLs must be fully qualified. The special x-default value marks the page to show when no listed language matches the visitor. Language codes follow ISO 639-1, with an optional ISO 3166-1 alpha-2 region such as en-GB. You can declare the alternates in HTML link tags, in HTTP headers or in the sitemap; Google treats the three as equivalent.
In Next.js both live under alternates: canonical for the one URL, and languages for the map of language code to URL. Relative paths resolve against metadataBase, and using one without metadataBase is a build error. This site avoids the question by building every URL as an absolute string in lib/seo.ts, so the same helper feeds the tags and the sitemap.
How do title templates, Open Graph and Twitter tags fit together?
title accepts a string or an object. title.template (with the required title.default) adds a prefix or suffix to titles set in child segments, but not to the segment that defines it. title.absolute sets a title that ignores any template from a parent. On a layout, absolute also becomes the default title for children that set none. The trap is below in the real example: a layout that sets only an absolute title leaves its children with no template at all.
Read more (1)Show less
Open Graph is a separate protocol read by link previews. Its four required properties are og:title, og:type, og:image and og:url, and og:description, og:locale, og:locale:alternate and og:site_name are optional. Next.js builds them from the openGraph object, and the docs say image URLs must be absolute. The twitter object works the same way, and card: summary_large_image asks for the large preview. Their titles can differ from the page title: a social card has no use for a keyword tail.
How do sitemap.ts, robots.ts and JSON-LD work in Next.js?
app/sitemap.ts exports a function returning an array of entries with url, lastModified, changeFrequency, priority, images, videos and, since Next.js 14.2, alternates.languages. app/robots.ts returns rules with userAgent, allow and disallow plus an optional sitemap. Both are special Route Handlers that are cached by default unless they use a request-time API. Google's own sitemap guide adds the limits: 50 MB or 50,000 URLs per file, absolute URLs only, and priority and changefreq values are ignored. It uses lastmod only when it is consistently and verifiably accurate, so a date that is always today gives it nothing to use.
Read more (2)Show less
robots.txt manages crawler traffic. Google is explicit that it is not a way to keep a page out of search: a disallowed page can still be indexed if other sites link to it, and noindex or a password is the way to keep it out.
For structured data, the Next.js guide recommends rendering JSON-LD as a script tag in a layout or page, using a plain script element rather than next/script, because it is data and not executable code. Its snippet replaces the less-than character with its unicode escape, since JSON.stringify does not sanitise strings that could break out of the script. Google recommends JSON-LD when your setup allows it and says never to mark up information that is not visible to the user. The Rich Results Test and the Schema Markup Validator check the output.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
export function buildCanonical(locale: SiteLocale, path = ""): string {
const url = `${BASE_URL}/${locale}${path}`;
return url.endsWith("/") ? url : `${url}/`;
}
export function buildLanguageAlternates(path = ""): Record<string, string> {
return {
en: buildCanonical("en", path),
id: buildCanonical("id", path),
"x-default": buildCanonical("en", path),
};
}Every URL on the site is built here, and nowhere else. buildCanonical always returns an absolute address with a trailing slash, matching the site's trailingSlash setting. buildLanguageAlternates returns en, id and x-default, and x-default points at English.
return {
// Absolute: post titles are written to sit inside Google's ~63-character
// budget, and the root layout's "%s | Matthews Wong" template would add 16
// more and truncate every one of them. That template used to be suppressed
// by the plain-string title in blog/layout.tsx, which no longer sets one.
title: { absolute: title },
description,
keywords: blog.keywords,
alternates: {
canonical,
languages: buildLanguageAlternates(routePath),
},A post's metadata. The canonical is the page's own URL in its own language, and the language map comes from the same helper. The title is absolute because it is already written to fit about 63 characters; the root template would add a brand tail and get it truncated.
title: {
default: seo.title,
// Kept as a safety net for any route that sets a plain-string title.
// Every page with length-budgeted copy sets `title: { absolute }`, which
// bypasses this so the brand tail is not appended twice.
template: "%s | Matthews Wong",
},The root layout keeps a template, but its own comment calls it a safety net. The site's convention is that every page sets title.absolute with its brand tail already included, so the template rarely fires.
// Absolute: the title already carries the brand tail, so the root
// layout's "%s | Matthews Wong" template would append a second one.
title: { absolute: seo.title },This layout sets only an absolute title. In Next.js 15.5.7 the template passed to child segments comes from the nearest segment that declares one, so everything under /education loses the root template. The thesis page below it says so in a comment and sets its own absolute title.
url: buildCanonical(locale, path),
lastModified: routeLastModified[path],
priority: route.priority,
changeFrequency: route.changeFrequency,
images: routeImages[path],
videos: routeVideos[path]?.(locale),
alternates: {
languages: buildLanguageAlternates(path),
},One sitemap entry. The URL, the language alternates and the last-modified date all come from data, and the alternates reuse the helper from lib/seo.ts, so the sitemap and the head tags cannot disagree. Google ignores the priority and changeFrequency lines here; they are harmless but do no work.
const ALLOW = ["/", "/api/v1/"];
const DISALLOW = ["/private/", "/admin/", "/api/"];
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: ALLOW, disallow: DISALLOW },
...AI_CRAWLERS.map((userAgent) => ({
userAgent,
allow: ALLOW,
disallow: DISALLOW,
})),
],
host: BASE_URL,
sitemap: `${BASE_URL}/sitemap.xml`,
};
}robots.ts builds its rules from two shared arrays, so the generic crawler and every named AI crawler get the same policy. The sitemap line points crawlers at the one registry of real pages.
{articleSchema && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
)}JSON-LD from a server component, as the docs recommend: a native script tag fed by plain data. What I would change: the docs sanitise the output with a replace of the less-than character, and this code does not. The data here is ours, but the habit costs one call.
Mistakes that cost real time
An absolute title above a plain-string child
A layout with title absolute and no template gives its children no template. A child page that sets a plain string and expects the root brand tail ships without it. Nothing fails: tsc and the build pass, and only the rendered title differs. Pick one mode per subtree, and check the built HTML, not the source.
A nested openGraph that wipes the parent's
Metadata merges shallowly, so a page that sets openGraph.title drops the description and images the layout declared. The share card loses its image. Spread a shared object into each segment's openGraph, or set the whole object once.
hreflang that points one way
If the English page lists the Indonesian one but the Indonesian page does not list itself and the English page back, Google ignores the tags. Generate both sides from one function, as lib/seo.ts does, and keep the URLs absolute.
Relative image URLs without metadataBase
A relative URL in a metadata field needs metadataBase, or the build fails. Even with it, the Open Graph examples in the docs mark image URLs as needing to be absolute, so this site resolves every image with toAbsoluteUrl before it reaches the tags.
Blocking in robots.txt to hide a page
Disallow stops crawling, not indexing. A blocked URL that is linked elsewhere can still appear in results. Use noindex or authentication for pages that must stay out.
| Setting | On the segment itself | On child segments |
|---|---|---|
| title: string | Wrapped by the parent's template | Default title for children that set none |
| title.template + default | Not applied to itself | Wraps every child title |
| title.absolute | Ignores the parent's template | Becomes their default; no template passes down |
Do it on your own machine
You need Node.js and a Next.js 15 App Router project. Build the metadata for one bilingual page, then read the built HTML rather than trusting the source.
Create a helper that builds absolute canonical and alternate URLs, and set metadataBase in the root layout with a title template and default.
const BASE_URL = "https://example.com"; export const canonical = (locale: string, path = "") => `${BASE_URL}/${locale}${path}/`; export const languages = (path = "") => ({ en: canonical("en", path), id: canonical("id", path), "x-default": canonical("en", path), });In a page, export generateMetadata. Return an absolute title, a description, alternates and an openGraph object that repeats everything you want shared.
export async function generateMetadata({ params }) { const { locale } = await params; const url = canonical(locale, "/guide"); return { title: { absolute: "Guide | Example" }, description: "A guide.", alternates: { canonical: url, languages: languages("/guide") }, openGraph: { title: "Guide", url, type: "article", images: ["https://example.com/og.png"] }, }; }Add app/sitemap.ts and app/robots.ts. Reuse the helper for the alternates, and point robots at the sitemap.
export default function sitemap() { return [{ url: canonical("en", "/guide"), lastModified: "2026-10-17", alternates: { languages: languages("/guide") } }]; }Render JSON-LD in the page component as a plain script tag, escaping the less-than character in the output.
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }} />Build and start the app, then read the real head with curl. Check the title, the canonical and the hreflang links on the /id/ page. Then try a title string under a layout that sets only an absolute one and watch the brand tail disappear.
npm run build && npm start curl -s http://localhost:3000/id/guide/ | grep -o '<title>[^<]*</title>\|<link rel="[a-z]*"[^>]*>'
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Why must a /id/ page's canonical point at the /id/ URL and not the /en/ one?Show the answer
Answer:A canonical says which URL is the preferred version of this page. Pointing it at the English page tells Google to treat the Indonesian page as a duplicate. Each language names itself, and hreflang links the two.
2.Why use a native script tag and not next/script for JSON-LD?Show the answer
Answer:next/script is built for loading and running JavaScript. JSON-LD is data, not executable code, so the docs call a plain script tag the right choice. Escape the less-than character, since JSON.stringify does not sanitise it.
3.Which file should you add to keep a page out of Google, and which one should you not?Show the answer
Answer:Use noindex or put the page behind a password. A robots.txt disallow only blocks crawling, and the URL can still be indexed if other sites link to it.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- Next.js docs: generateMetadata
- Next.js docs: sitemap.xml
- Next.js docs: robots.txt
- Next.js docs: JSON-LD
- Google Search Central: localized versions and hreflang
- Google Search Central: canonical URLs
- Google Search Central: build a sitemap
- Google Search Central: introduction to robots.txt
- Google Search Central: introduction to structured data
- The Open Graph protocol