SEO in Next.js App Router is genuinely good once you understand the three pieces that work together. The mistake I see in most templates and starter kits is treating them as three separate problems. They aren't — they're one system.
This is a walkthrough of the exact pattern I settled on after applying it across 20 production Next.js 15 templates covering niches from SaaS and real estate to e-commerce and hospitality. All code below is from those templates, not contrived examples.
The three pieces
- A single typed content config — all text, URLs, and brand values in one place
-
generateMetadata— static for most pages, dynamic for data-driven routes -
sitemap.tsandrobots.ts— auto-generated from the same config
Get these three to read from the same source and SEO maintenance essentially disappears.
Piece 1: the content config
Every template has a src/lib/content.ts (or data.ts) that exports typed constants for everything the site needs to display — and everything the meta layer needs to index it.
// src/lib/content.ts
export const SITE = {
name: "Nexus",
tagline: "The platform that scales with you.",
description:
"Analytics, automation, and collaboration in one platform. Ship faster, decide smarter, grow without limits.",
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://nexus.example.com",
email: "hello@nexus.example.com",
twitter: "@nexushq",
} as const;
as const is load-bearing: TypeScript narrows the types to literals, so if you reference SITE.name in a metadata title template, you get a type error the moment you accidentally delete the field. The entire SEO layer catches regressions at build time, not after a deploy.
The key discipline: if a value appears in a heading, it should also appear in the metadata. Both read from the same object. No copy-paste drift.
Piece 2a: static metadata (the layout level)
For the root layout, export metadata directly. The title.template field is the one most people skip — it lets every child page get a consistent "Page name · Nexus" format for free, without each page re-specifying the brand.
// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import { SITE } from "@/lib/content";
export const metadata: Metadata = {
metadataBase: new URL(SITE.url), // resolves relative OG image paths
title: {
default: `${SITE.name} — ${SITE.tagline}`, // home page
template: `%s · ${SITE.name}`, // every other page
},
description: SITE.description,
alternates: { canonical: "/" },
openGraph: {
title: `${SITE.name} — Modern SaaS Platform`,
description: SITE.description,
type: "website",
url: SITE.url,
siteName: SITE.name,
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: `${SITE.name} — Modern SaaS Platform`,
description: SITE.description,
site: SITE.twitter,
},
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large" },
},
};
export const viewport: Viewport = {
themeColor: "#0a0a0f",
colorScheme: "dark",
};
Three things worth noting:
-
metadataBaseis required for Open Graph images to resolve. Skip it and Next.js will warn on every build that your OG image URLs are relative. -
Viewportis now separate fromMetadatain Next.js 15. PuttingthemeColorinsidemetadatastill works but triggers a deprecation warning. Keep them in separate exports. -
robotsin metadata generates the<meta name="robots">tag, notrobots.txt. You still wantrobots.tsfor the actual file (covered below).
Piece 2b: dynamic metadata for data-driven routes
Static export const metadata doesn't work when the page content changes per URL — a real estate listing, a blog post, a product page. For those, export generateMetadata instead.
Here's the real-estate listing detail page from one of the templates:
// src/app/ilan/[slug]/page.tsx
import type { Metadata } from "next";
import { listings, formatPrice } from "@/data/listings";
// 1. Pre-render at build time (optional but recommended for static content)
export function generateStaticParams() {
return listings.map((l) => ({ slug: l.slug }));
}
// 2. Per-page SEO from the data
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>; // <-- Promise in Next.js 15
}): Promise<Metadata> {
const { slug } = await params;
const listing = listings.find((l) => l.slug === slug);
if (!listing) return { title: "Listing not found" };
return {
title: `${listing.title} — ${formatPrice(listing)}`,
description: listing.description.slice(0, 155),
alternates: { canonical: `/ilan/${listing.slug}` },
openGraph: {
title: `${listing.title} — ${formatPrice(listing)}`,
description: listing.description.slice(0, 155),
type: "website",
},
};
}
The params is now a Promise in Next.js 15 — this catches people who copy patterns from Next.js 13/14. If you destructure params synchronously (the old way), you get a runtime error in production even though it might work locally.
The description slice at 155 characters is intentional: Google truncates around 160. Slice at the data level so the metadata object always has a valid string, not a value that might be 3,000 characters from a rich text field.
Piece 3: sitemap.ts and robots.ts
Both are just TypeScript files that export functions. Next.js calls them at build time and serves the results as /sitemap.xml and /robots.txt. No plugin needed.
The key: the sitemap reads from the same data array as the pages. Add a listing, add a blog post — the sitemap updates on the next build without any manual maintenance.
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
import { listings } from "@/data/listings";
const siteUrl = "https://estora.com"; // same as SITE.url; centralize if you want
export default function sitemap(): MetadataRoute.Sitemap {
const staticRoutes: MetadataRoute.Sitemap = [
{ url: siteUrl, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${siteUrl}/listings`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
{ url: `${siteUrl}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
{ url: `${siteUrl}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
];
const listingRoutes: MetadataRoute.Sitemap = listings.map((l) => ({
url: `${siteUrl}/ilan/${l.slug}`,
lastModified: new Date(l.listedDate),
changeFrequency: "weekly" as const, // <-- const assertion required to satisfy the union type
priority: 0.8,
}));
return [...staticRoutes, ...listingRoutes];
}
// src/app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: "https://estora.com/sitemap.xml",
};
}
The "weekly" as const on the dynamic routes is a small gotcha: changeFrequency is a string union type, and TypeScript will complain if the inferred type is just string. Either use as const on the value or annotate the array with MetadataRoute.Sitemap explicitly (which forces the type down).
Putting it together: what the build output looks like
Run next build and you get:
Route (app) Size
├ ○ / 5.2 kB
├ ○ /about 3.1 kB
├ ● /ilan/[slug] 4.8 kB
│ ├ /ilan/modern-penthouse
│ ├ /ilan/garden-apartment
│ └ ... (12 more pages)
├ ○ /sitemap.xml
└ ○ /robots.txt
Every listing page is pre-rendered with its own title, description, and canonical URL baked in. The sitemap lists all of them. No client-side rendering, no runtime SEO gaps.
The one mistake that costs rankings
Canonical URLs. Most templates either skip them or put a hardcoded string in every page file. Both are wrong.
The right approach: canonical at the layout level defaults to the root path, then each dynamic page overrides it with alternates: { canonical: \/ilan/\${slug}}. Pages that don't override inherit the root canonical, which is fine for static pages. Pages that do override it tell Google exactly which URL is the source of truth.
Without the override on dynamic routes, you can end up with dozens of listing pages all canonicalizing to /, which signals duplicate content to crawlers even though the pages are distinct.
Why this pattern scales
When you apply this to 20 templates across completely different domains, the pattern holds because it's mechanical: one config object, one metadata export per route level, one sitemap function. The content changes; the SEO structure doesn't.
The templates I mentioned earlier cover SaaS, real estate, restaurants, e-commerce, gyms, dental clinics, hotels, law firms, and more. Every one uses this exact three-piece pattern. Live demos are at cenkkurtoglu.com/templates if you want to inspect the rendered markup — check the source on any listing page and you'll see the pattern in action.
The code is also the starting point if you'd rather skip the boilerplate and build the domain-specific logic directly.
Quick reference
| What | Where | Key detail |
|---|---|---|
| Brand/URL constants | src/lib/content.ts |
as const, single source |
| Root metadata | src/app/layout.tsx |
metadataBase, title.template, separate viewport
|
| Dynamic page metadata |
generateMetadata in [slug]/page.tsx
|
params is a Promise in Next.js 15 |
| Static pre-rendering | generateStaticParams |
Pair with generateMetadata
|
| Sitemap | src/app/sitemap.ts |
Reads same data array as pages |
| Robots | src/app/robots.ts |
Points to sitemap URL |
| Canonicals | alternates.canonical |
Override per dynamic route |
Top comments (0)