Technical SEO in the Next.js App Router: Metadata, Sitemaps and JSON-LD
Learn how shared metadata helpers, root layout defaults, robots.ts, a dynamic sitemap, honest JSON-LD and page audits give a Next.js app a clean SEO base.
This article is published in English.
Sources: How I Got a Next.js Project to 100% SEO on Lighthouse
A Next.js app can look finished and still be hard for search engines to read: pages lack canonical URLs, crawler rules are undefined, and new content never reaches a sitemap. This walkthrough follows a mock-data API site with many resource pages from its first Lighthouse SEO audit to a score of 100, covering the seven changes made with the App Router's built-in metadata APIs.
Build page metadata from one helper
Hand-writing metadata in every page leads to drift, such as a missing canonical URL here or a stale Open Graph title there. A small factory fixes this: each page passes only its title, description, path and an optional noIndex flag, and the helper expands them into canonical, robots and Open Graph fields:
export function createPageMetadata({
title,
description,
path,
noIndex = false,
}: PageMetadata) {
return {
title,
description,
alternates: {
canonical: path,
},
robots: noIndex ? "noindex, nofollow" : "index, follow",
openGraph: {
title,
description,
url: path,
type: "website",
},
};
}
Indexing is opt-out: one flag switches a page to noindex, nofollow. Because path is relative, the helper relies on a base URL defined in the root layout.
Put site-wide defaults in the root layout
Everything that is shared across the site belongs in the metadata export of layout.tsx:
metadataBase- a title template
- a default description
- Open Graph defaults
- Twitter card settings
- robots defaults
- the
langattribute on the<html>element
A trimmed version of that export looks like this:
export const metadata: Metadata = {
metadataBase: new URL("https://mockdata.ir"),
title: {
default: "MockData",
template: "%s | MockData",
},
description: "...",
};
With the %s | MockData template, a page titled "Users API" renders as "Users API | MockData", while untitled pages use default. metadataBase is what resolves the helper's relative canonical and Open Graph url values into absolute URLs. Note that lang is not a metadata field; set it on the <html> tag the layout renders.
Define crawler rules with robots.ts
Rather than leaving crawler rules undefined, generate robots.txt from an app/robots.ts file returning a typed MetadataRoute.Robots object:
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://mockdata.ir/sitemap.xml",
};
}
It is deliberately minimal: all user agents may crawl everything, and the sitemap location is advertised.
Generate the sitemap from your data
With resource pages for users, posts, products and more, a hand-maintained XML sitemap does not scale. A sitemap.ts default export can be async, so it can query the same source the pages use:
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const resources = await getPublicResources();
return [
{
url: "https://mockdata.ir",
lastModified: new Date(),
},
...resources.map((resource) => ({
url: `https://mockdata.ir/${resource.slug}`,
lastModified: resource.updatedAt,
})),
];
}
Each resource maps to a URL with updatedAt as lastModified, so new public resources join the sitemap automatically. One refinement: new Date() for the home page claims a change on every generation; a real content timestamp is more honest.
Add structured data only where it is true
JSON-LD with Schema.org vocabulary tells search engines what a page represents. The app itself is described as a SoftwareApplication, and the types in use are:
WebSiteOrganizationSoftwareApplicationBreadcrumbList
Aim for accuracy, not volume: markup must describe content that is really on the page. Invented reviews, ratings or other made-up properties are misleading and can violate search engine guidelines.
Keep breadcrumbs consistent between UI and schema
Nested documentation pages show a visible trail such as Home → Docs → Users API, and the same hierarchy is emitted as BreadcrumbList structured data. The visible trail and the markup should always describe the same structure.
Audit every indexable page by hand
Finally, review each public page individually:
- Does it have exactly one clear H1?
- Does the title describe this specific page?
- Does the description match the real content?
- Are there useful internal links to and from it?
- Should this page be indexed at all?
- Is any empty or low-value page exposed to crawlers?
Not every route belongs in search results; noindex is often the right call. For heading structure, see this guide to semantic HTML for the markup side.
What a perfect Lighthouse SEO score does and does not mean
After these fixes, Lighthouse reported an SEO score of 100. That number checks technical basics such as titles, descriptions and crawlability; it says nothing about ranking. Rankings depend on content quality, search intent, authority, backlinks, competition, performance and many other factors.
The real value is the clean foundation. Running the audit before shipping any Next.js app is cheap and catches many small mistakes.
Key takeaways
- Centralize per-page metadata in a helper and set shared defaults, including
metadataBase, in the root layout. - Use
robots.tsand an asyncsitemap.tsso crawler rules and URLs stay in sync with your data. - Add only structured data that truthfully describes the page, and keep breadcrumbs identical in UI and schema.
- Treat
noindexas a legitimate choice for pages that should not be in search results. - Read a Lighthouse score of 100 as "technically sound", not as a ranking guarantee.