Key Takeaway
To set up SEO metadata in the Next.js App Router, set metadataBase and a title template in the root layout, then export generateMetadata from each page with a title, description, canonical and hreflang alternates, and an openGraph object that repeats every field you want shared. Add sitemap.ts and robots.ts, then read the built HTML to confirm.
You open the page source of your new Next.js site and the head holds one title and nothing else. Or you added a title in a layout and every page on the site now shows the same one. Or the share card on WhatsApp has the right title but no image. Beginners usually get stuck on one of these three, because the Metadata API is small but its merge rules are not obvious.
This guide builds the metadata of one bilingual page in six steps. Every field name and code shape was checked against the Next.js documentation and Google Search Central, and the examples use example.com as a placeholder. The same pattern runs this site: one helper in lib/seo.ts builds every canonical and hreflang URL, and the head tags and the sitemap both read it, so they cannot disagree.
Want the concepts first, including JSON-LD and why each tag exists? Take the free lesson Next.js SEO metadata tutorial: generateMetadata to JSON-LD in the Web app skills track. This post is the hands-on walkthrough.
What you need
- A Next.js project using the App Router (the app folder), with TypeScript.
- A route with a locale segment, for example app/[locale]/guide/page.tsx. If your site has one language, ignore the locale parts.
- Node.js and a terminal, to run a production build and read the real HTML with curl.
- Your real site address. This guide uses https://example.com as a stand-in.
Not set up for two languages yet? Read the next-intl guide for Next.js i18n, then come back for the metadata.
Step 1: Build absolute URLs in one helper
Start with the part that every later step reuses: a helper that returns absolute canonical and hreflang URLs. Put it in lib/seo.ts. Writing URLs by hand in each page is how the English and Indonesian versions end up pointing at each other incorrectly.
// lib/seo.ts: satu-satunya tempat URL dibangun
export const BASE_URL = "https://example.com";
export function buildCanonical(locale: "en" | "id", 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), // cadangan: halaman Inggris
};
}Then set metadataBase and a title template once, in the root layout. According to the docs, a relative path in a URL field without metadataBase is a build error, and title.template applies to child segments but not to the segment that defines it, which is why title.default is required with it.
// app/[locale]/layout.tsx
import type { Metadata } from "next";
import { BASE_URL } from "@/lib/seo";
export const metadata: Metadata = {
metadataBase: new URL(BASE_URL),
title: {
default: "Example", // wajib ada kalau memakai template
template: "%s | Example", // berlaku untuk segmen ANAK, bukan layout ini
},
};Expect no output yet. The proof comes in Step 6. A page that sets title as a plain string now gets the brand tail added by the template; one that does not set a title gets title.default.
Step 2: Export generateMetadata from the page
Use a fixed metadata object when the values never change, and an async generateMetadata function when they depend on route params or data. In Next.js 15 the params are a Promise, so you await them. Both exports only work in Server Components, and you cannot export both from the same segment.
Do not add use client to a page that exports metadata. Keep the page a Server Component and move the interactive part into its own file, as the docs recommend.
// Wrong: use client + metadata di file yang sama. Metadata hanya jalan di Server Component.
"use client";
export const metadata = { title: "Panduan" };
// Wrong: dua ekspor metadata dari satu segmen.
export const metadata = { title: "Panduan" };
export async function generateMetadata() { /* ... */ }// app/[locale]/guide/page.tsx: Server Component, tanpa "use client"
import type { Metadata } from "next";
type Props = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params; // di Next.js 15, params adalah Promise
return {
// absolute: judul sudah memuat merek, jadi template root tidak menambah lagi
title: { absolute: "Panduan SEO Metadata | Example" },
description: "Contoh deskripsi 140 sampai 155 karakter untuk halaman ini.",
};
}The title here is the absolute form, with the brand already written in. The next step fills the alternates in. If you fetch data inside generateMetadata, the docs say identical fetch requests are memoised across generateMetadata, layouts and pages, so the page itself can ask for the same data without a second request.
Step 3: Add canonical and hreflang for each language
Each language version names itself as canonical, and alternates.languages lists every version. Google says each version must list itself as well as all the others, that the links must be reciprocal or the tags are ignored, and that URLs must be fully qualified. It recommends absolute paths in the canonical element and a self-referential canonical on the canonical page.
import { buildCanonical, buildLanguageAlternates } from "@/lib/seo";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const lang = locale === "id" ? "id" : "en";
const path = "/guide";
return {
title: { absolute: "Panduan SEO Metadata | Example" },
alternates: {
canonical: buildCanonical(lang, path), // URL halaman ini sendiri
languages: buildLanguageAlternates(path), // en, id, x-default
},
};
}
// Wrong: canonical halaman /id/ menunjuk ke versi Inggris
// canonical: buildCanonical("en", path)The x-default entry is the fallback for visitors whose language matches none of your versions. This site points it at the English page. Because both sides come from the same function, the reciprocal rule cannot be broken by a typo.
Google treats the three ways of declaring alternates, HTML link tags, HTTP headers and the sitemap, as equivalent, so one is enough. This guide uses the link tags and also fills them in the sitemap in Step 5.
Step 4: Add Open Graph and Twitter without losing fields
Open Graph and Twitter tags control link previews. Set openGraph with title, description, url, type and images, and twitter.card to summary_large_image for a large preview. The docs mark image URLs as needing to be absolute.
Metadata merges shallowly. If a layout sets openGraph with a description and an image, and a page sets openGraph with only a title, the page's card has only a title. The whole object is replaced, not combined.
// Wrong: layout punya openGraph lengkap, page mengganti SELURUH objeknya.
// layout: openGraph: { title, description, images }
// page: openGraph: { title: "Panduan" }
// hasil: hanya og:title. Description dan gambar hilang.// lib/shared-metadata.ts
export const sharedOpenGraph = {
siteName: "Example",
images: ["https://example.com/og/default.png"], // wajib absolut
};
// app/[locale]/guide/page.tsx
return {
title: { absolute: "Panduan SEO Metadata | Example" },
openGraph: {
...sharedOpenGraph, // gambar dan siteName ikut terbawa
title: "Panduan SEO Metadata",
description: "Ringkasan untuk kartu bagikan.",
url: buildCanonical(lang, path),
type: "article",
},
twitter: { card: "summary_large_image" },
};The fix is to pull the shared fields into one object and spread it into each segment, which is the pattern the docs show. For a unique image per page, see dynamic OG image generation in Next.js. Social apps cache previews, so a card that is still wrong after you fix the tags may just be a stale copy.
Step 5: Generate sitemap.ts and robots.ts
app/sitemap.ts returns an array of entries, each with a url and optional lastModified and alternates.languages (localisation support arrived in Next.js 14.2). Reuse the Step 1 helper so the sitemap agrees with the head tags. The file is served as /sitemap.xml.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { buildCanonical, buildLanguageAlternates } from "@/lib/seo";
const PATHS = ["", "/guide"];
export default function sitemap(): MetadataRoute.Sitemap {
return PATHS.flatMap((path) =>
(["en", "id"] as const).map((locale) => ({
url: buildCanonical(locale, path),
lastModified: "2026-10-17", // tanggal konten benar-benar berubah
alternates: { languages: buildLanguageAlternates(path) },
})),
);
}app/robots.ts returns rules with userAgent, allow and disallow, plus a sitemap address. Both files are special Route Handlers that are cached by default unless they use a request-time API. Keep the lastModified honest: use the date the content really changed, not new Date() on every build.
// app/robots.ts
import type { MetadataRoute } from "next";
import { BASE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/private/" },
sitemap: `${BASE_URL}/sitemap.xml`,
};
}This is the shape the generated robots.txt takes for the example above, following the docs' output format.
# /robots.txt hasil generate
User-Agent: *
Allow: /
Disallow: /private/
Sitemap: https://example.com/sitemap.xmlStep 6: Check the built HTML, not the source
Source code can look right while the page ships something else, because title templates and merging happen at build time. Run a production build, start it, and read the real response with curl. Check the Indonesian page, not only the English one.
npm run build && npm start
# Dari terminal lain: baca head halaman Indonesia yang SEBENARNYA
curl -s http://localhost:3000/id/guide/ \
| grep -oE '<title>[^<]*</title>|<link rel="(canonical|alternate)"[^>]*>|<meta property="og:image"[^>]*>'
# Yang dicari (urutan atribut bisa berbeda):
# <title>Panduan SEO Metadata | Example</title>
# <link rel="canonical" href="https://example.com/id/guide/"/>
# <link rel="alternate" hreflang="en" href="https://example.com/en/guide/"/>
# <link rel="alternate" hreflang="id" href="https://example.com/id/guide/"/>
# <link rel="alternate" hreflang="x-default" href="https://example.com/en/guide/"/>
# <meta property="og:image" content="https://example.com/og/default.png"/>
# Dua file lainnya
curl -s http://localhost:3000/robots.txt
curl -s http://localhost:3000/sitemap.xml | head -20Look for four things: one title with the brand tail exactly once, a canonical that is this page's own URL in its own language, an hreflang link for each language plus x-default, and an og:image that starts with https. The exact attribute order may differ from what you expect, so match on the tag names.
If a tag is missing here, no tool downstream will fix it. Go back to the step that owns it.
Common errors and how to fix them
Most metadata mistakes fail silently: the build passes and only the output is wrong. This table maps what you see to what causes it.
| Symptom | Cause | Fix |
|---|---|---|
| The build fails on a metadata URL field | A relative path such as /og.png is used and no metadataBase is set. | Set metadataBase to new URL of your site in the root layout, or write absolute URLs. |
| A child page has no brand tail in its title | The template was defined in the same segment as the title, or a parent segment set an absolute title and no template of its own. | Define the template in a parent layout, and use one mode per subtree. Check the built HTML. |
| The brand appears twice in the title | The title already ends with the brand and the root template adds it again. | Use title.absolute on pages whose title already includes the brand. This site does exactly that for every length-budgeted page. |
| The share card lost its image or description | A page set openGraph, which replaced the layout's whole openGraph object. | Spread a shared object into every segment's openGraph, or repeat the fields you still want. |
| Google ignores my hreflang | The links are not reciprocal, a version does not list itself, or the URLs are relative. | Generate every language's alternates from one function and keep the URLs absolute. |
| The Indonesian page's canonical points to the English page | The English URL was copied as the canonical for every language. | Each language names its own URL as canonical, and hreflang links the versions. |
| Error when exporting metadata | The page has use client, or exports both metadata and generateMetadata. | Keep the page a Server Component, move interactivity to a separate file, and export only one of the two. |
Checklist
- metadataBase and a title template with a default are set once in the root layout.
- Every page exports one of metadata or generateMetadata, from a Server Component.
- Canonical and hreflang URLs come from one helper, are absolute, and each language names itself.
- openGraph is spread from a shared object or fully repeated, with an absolute image URL.
- sitemap.ts and robots.ts exist, and robots.ts names the sitemap address.
- You read the built HTML of the Indonesian page with curl and saw each tag once.
Learn more
This post is the do-it-yourself path. The free lesson explains why each tag exists, adds JSON-LD from a server component, and walks through the real files behind this site, with a short quiz at the end.
Open the lesson: Next.js SEO metadata tutorial
The rule to carry away: decide each URL once, in one helper, and let every tag and the sitemap read it. Then verify the built HTML, because the mistakes that matter here never fail the build.

