# Cara Membuat SEO Metadata Next.js App Router untuk Pemula

> Cara membuat SEO metadata Next.js App Router for beginners: generateMetadata, canonical, hreflang, Open Graph, sitemap.ts, robots.ts and common errors.

URL: https://www.matthewswong.com/en/blog/cara-membuat-seo-metadata-nextjs-app-router/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-10-17
- Updated: 2026-10-17
- Category: Web Dev

> **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.

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.

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.

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.

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.

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/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.

This is the shape the generated robots.txt takes for the example above, following the docs' output format.

## Step 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.

Look 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.

## Sources

- Next.js docs: generateMetadata
- Next.js docs: sitemap.xml
- Next.js docs: robots.txt
- Google Search Central: localized versions and hreflang
- Google Search Central: canonical URLs

## Source links

- https://nextjs.org/docs/app/api-reference/functions/generate-metadata
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots
- https://developers.google.com/search/docs/specialty/international/localized-versions
- https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls

## FAQ

### Why does my Next.js page show the wrong title or no brand name?

Next.js applies title.template only to child segments, not to the segment that defines it. A parent layout that sets only an absolute title also passes no template down. Define the template in a parent layout, use title.absolute on pages that already carry the brand, and check the built HTML.

### Why does my Open Graph image disappear when I set openGraph on a page?

Metadata is merged shallowly, so an openGraph object in a page replaces the whole openGraph object from the layout. The image and description you declared there are gone. Put the shared fields in one object and spread it into each segment, or repeat them.

### Do I need metadataBase in Next.js?

You need it if any URL-based metadata field uses a relative path. Without it, Next.js fails the build. If you write every URL as an absolute string, metadataBase is not required, but setting it once in the root layout is the simplest safeguard.

### Should the Indonesian page's canonical point to the English page?

No. Each language version should name its own URL as canonical, and hreflang links the versions together. Google recommends a self-referential canonical, and requires hreflang links to be reciprocal and fully qualified or it ignores them.

### Can I use sitemap.ts and robots.ts instead of static files?

Yes. app/sitemap.ts and app/robots.ts are special Route Handlers that return typed objects, and Next.js serves them as /sitemap.xml and /robots.txt. They are cached by default unless they use a request-time API, so reuse your URL helper in them.

---

- HTML version: https://www.matthewswong.com/en/blog/cara-membuat-seo-metadata-nextjs-app-router/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/cara-membuat-seo-metadata-nextjs-app-router/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
