Nextjs 16 SEO Metadata in the App Router: Canonical urls, robots, and my weird slug experiments
5 min read

Let’s talk about metadata
In the previous post I went deep on Open Graph / Twitter card images.
This time I’m staying on the boring-but-important side: SEO metadata in Next.js 16 App Router.
And yes… I’m going to make it slightly harder than it needs to be, because I run some experimental SEO tests on my own sites:
- I keep posts + categories under a flat slug namespace
- I do some abstract categorization tricks for nicer URLs
- none of this is a recommendation — it’s a playground
‼️ If you’re building something serious for a client: please don’t copy my chaos blindly.
Next.js 16 already gives you most of the SEO stuff
The big mindset shift with App Router:
- stop wiring everything through a custom
<Head> - stop building your own routes for basic SEO files
Instead, use the file conventions:
generateMetadata()for dynamic per-route metadataapp/robots.ts→ served as/robots.txtapp/sitemap.ts→ served as/sitemap.xml
I like this because:
- less plumbing
- less “where is this generated?” confusion
- it’s type-safe (
Metadata,MetadataRoute.*)
And in Next.js 16 specifically, there’s one detail that will absolutely bite you once:
params(and oftensearchParams) come in as Promises.
So when you see code like this:
export async function generateMetadata({params,}: {params: Promise<{ slug: string }>;}) {const { slug } = await params;// ...}
…it’s not a style choice.
It’s the contract.
My actual constraints
1) Flat slug structure
On my sites, categories and posts can end up sharing the same slug space.
That’s why I treat:
- posts as real pages with
ogType: "article" - category/tag listing pages as website pages
This matters because article metadata is slightly different:
publishedTime/modifiedTime- author URLs
- tags / keywords
…and you can keep your “real content pages” separated from your “navigation pages” without building two metadata systems.
2) Abstract categories (experimental)
I keep backend slugs “flat”, but I prefer URLs like:
art-history/1-1instead ofart-history-1-1
So I have helpers like:
asSlug(url)→ converts incoming URL slugs back to backend slugsasUrl(slug)→ converts backend slugs into canonical URL paths
That means:
- the API slug is not the same as the public canonical URL
- canonical must be constructed from the url space, not from the backend slug space
‼️ Again: this is purely a sandbox. You will almost certainly have a different setup.
The pattern: keep generateMetadata() tiny, push logic into a helper
I like generateMetadata() to read like a tiny story:
- fetch the entity (if needed)
- decide
ogType - pass it into a generator
That generator is where I keep the boring rules:
- page title formatting
- description fallback
- canonical construction
- truncation (so SERP titles don’t look like a ransom note)
This does two things:
- makes metadata consistent across the entire app
- makes experimentation possible without breaking 40 routes
The generator
I keep text rules in one place because I’m allergic to copy-paste.
1) A tiny text util
This is the “please don’t ruin my SERP preview” function.
It cuts:
- at a clean character limit
- optionally at a word/dash boundary
- and always includes ellipsis inside the budget
// lib/utils/text.utils.tsexport function truncateText(text: string,max: number,opts: { ellipsis?: string; preferWordBoundary?: boolean } = {},): string {const ellipsis = opts.ellipsis ?? "…";const preferWordBoundary = opts.preferWordBoundary ?? true;const clean = (text ?? "").trim();const limit = Math.max(0, max);if (clean.length <= limit) return clean;if (limit === 0) return "";if (limit <= ellipsis.length) return ellipsis.slice(0, limit);const target = limit - ellipsis.length;const slice = clean.slice(0, target);if (!preferWordBoundary) {return `${slice}${ellipsis}`;}const lastSpace = slice.lastIndexOf(" ");const lastDash = slice.lastIndexOf("-");const cut = Math.max(lastSpace, lastDash);const boundaryOk = cut >= Math.floor(target * 0.6);const base = boundaryOk ? slice.slice(0, cut).trimEnd() : slice.trimEnd();return `${base}${ellipsis}`;}
If you ever wondered why some sites look like they got their titles cut with a chainsaw:
- it’s usually because
siteNamegot appended after truncation.
Which leads to the next part.
2) Title builder (budget-aware)
I format titles as:
"<title> - <siteName>"
…and I cap it at ~60 chars.
The trick: reserve space for siteName first, then truncate only the page title.
// lib/metadata/metadata.utils.tsimport { BASE_URL, SITE_NAME } from "@/config/constants";import { truncateText } from "@/lib/text.utils";import { asUrl } from "./url-slug.utils";export function getAbsoluteUrl(pathOrUrl: string,baseUrl: string = BASE_URL,): string {if (!pathOrUrl) return baseUrl;if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {return pathOrUrl;}const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;return new URL(path, baseUrl).toString();}type PageTitleOptions = {siteName?: string;maxLength?: number;separator?: string;ellipsis?: string;};export function getPageTitle(title?: string | null,options: PageTitleOptions = {},): string {const siteName = (options.siteName ?? SITE_NAME).trim();const cleanTitle = (title ?? "").trim();if (!cleanTitle) return siteName;const maxLength = Math.max(1, options.maxLength ?? 60);const separator = options.separator ?? " - ";const ellipsis = options.ellipsis ?? "…";const full = `${cleanTitle}${separator}${siteName}`;if (full.length <= maxLength) return full;if (siteName.length >= maxLength) {return truncateText(siteName, maxLength, { ellipsis });}const availableForTitle = maxLength - siteName.length - separator.length;if (availableForTitle <= 0) {return truncateText(siteName, maxLength, { ellipsis });}const truncatedTitle = truncateText(cleanTitle, availableForTitle, {ellipsis,});return `${truncatedTitle}${separator}${siteName}`;}export function getPageDescription(description: string,siteName: string = SITE_NAME,): string {const cleanDescription = (description ?? "").trim();const cleanSiteName = (siteName ?? SITE_NAME).trim();const suffix = ` on ${cleanSiteName}`;const maxLength = 160;const full = `${cleanDescription}${suffix}`;if (full.length <= maxLength) {return full;}const availableForDescription = maxLength - suffix.length;if (availableForDescription <= 0) {return truncateText(cleanSiteName, maxLength);}const truncatedDescription = truncateText(cleanDescription,availableForDescription,);return `${truncatedDescription}${suffix}`;}export function getCanonicalUrl(baseUrl: string,slug?: string | null,prefix?: string,): string {const path = slug ? asUrl(slug, prefix) : "/";return getAbsoluteUrl(path, baseUrl);}
If you’ve ever had to debug “why is Google showing my title weird?”, this is one of the few places where a small rule saves a lot of screenshots.
3) metadataGenerator itself
This stays intentionally boring:
- title + description
- canonical
- openGraph/twitter text
(no images here — I handle those via opengraph-image.tsx)
// lib/metadata/meta-generator.tsimport type { Metadata } from "next";import {AUTHOR,BASE_URL,DEFAULT_DESCRIPTION,DEFAULT_LOCALE,SITE_NAME,TWITTER_CARD_TYPE,TWITTER_HANDLE,} from "@/config/constants";import type { PostDto } from "@/kodkafa/schemas";import { getPageDescription, getPageTitle } from "./metadata.utils";interface Options {ogType?:| "website"| "article"| "profile"| "book"| "music.song"| "music.album"| "music.playlist"| "music.radio_station"| "video.movie"| "video.episode"| "video.tv_show"| "video.other";authorName?: string;authorUrl?: string;twitterHandle?: string;twitterCardType?: "summary" | "summary_large_image" | "app" | "player";updatedAt?: string;title?: string;description?: string;canonicalPath?: string;}function toCanonicalUrl(base: string, canonicalPath?: string) {const path = canonicalPath?.startsWith("/") ? canonicalPath : "/";return new URL(path, base).toString();}export function metadataGenerator(data: PostDto | undefined,options: Options = {},): Metadata {const {ogType = "website",authorName = AUTHOR.name,authorUrl = AUTHOR.url,twitterHandle = TWITTER_HANDLE,twitterCardType = TWITTER_CARD_TYPE,updatedAt,canonicalPath = "/",} = options;const title = getPageTitle(options.title || data?.title, {siteName: SITE_NAME,});const description = getPageDescription(options.description || data?.description || DEFAULT_DESCRIPTION,);const canonicalUrl = toCanonicalUrl(BASE_URL, canonicalPath);const publishedTime = data?.createdAt;const modifiedTime = updatedAt || data?.updatedAt || publishedTime;const tags = data?.tags || [];const openGraphData: Metadata["openGraph"] = {title,description,url: canonicalUrl,siteName: SITE_NAME,locale: DEFAULT_LOCALE,type: ogType,...(ogType === "article" && publishedTime && { publishedTime }),...(ogType === "article" && modifiedTime && { modifiedTime }),...(ogType === "article" && authorUrl && { authors: [authorUrl] }),...(ogType === "article" && tags.length > 0 && { tags }),};const twitterData: Metadata["twitter"] = {card: twitterCardType,site: twitterHandle,creator: twitterHandle,title,description,};return {title,description,alternates: {canonical: canonicalUrl,},...(tags.length > 0 && { keywords: tags }),...(ogType === "article" &&authorName && { authors: [{ name: authorName, url: authorUrl }] }),openGraph: openGraphData,twitter: twitterData,};}
The thing I love here: metadataGenerator() doesn’t know about routes.
Routes pass in:
canonicalPathogType- optional overrides
…and the generator outputs consistent metadata.
Example: post page metadata
A post needs a fetch because:
- title/description come from content
- canonicalPath is derived from slug conversion
// app/posts/[slug]/page.tsxexport const revalidate = 2592000;export const dynamicParams = true;export async function generateMetadata({params,}: {params: Promise<{ slug: string }>;}) {const { slug } = await params;const domain = getApiDomain();const post = await fetchAndValidate<PostDto>({fetcher: () => postsControllerFindOneBySlug(domain, asSlug(slug)),schema: postsControllerFindOneBySlugResponse,context: "Post for metadata",defaultData: { title: "404 - Post not found" } as PostDto,});const ogType = post.type === "post" ? "article" : "website";const canonicalPath = asUrl(post.slug);return metadataGenerator(post, { ogType, canonicalPath });}
There is one boring reality here:
- your page probably also fetches the post
So yes, you might fetch twice.
If you hate that (I do), you can:
- push shared fetching into a cached helper
- or accept the cost because metadata generation is often cached and revalidated anyway
But I’m deliberately not turning this post into “let’s build a caching framework”.
Listing pages: semi-dynamic metadata without a content fetch
Some pages don’t need an API call just to produce metadata.
Example: my tags pages.
- the page itself fetches posts
- but metadata can be built just from the URL segment
Tags page metadata
// app/tags/[tags]/page.tsxexport const revalidate = 3600;export const dynamicParams = true;export async function generateMetadata({params,}: {params: Promise<{ tags: string }>;}) {const { tags: raw } = await params;const tags = raw.replaceAll("-", " ");return metadataGenerator(undefined, {title: tags,description: `Posts tagged with ${tags}`,canonicalPath: `/tags/${raw}`,ogType: "website",});}
This is the shape I like for category/listing pages too:
- if a listing page has no cover image, I fall back to template OG images using
title + description - if I later decide a category deserves a real “hero” image, I can add it without rewriting metadata logic
Canonical URLs: you do you
Canonical URLs are the one part I won’t try to “standardize” for you.
Because even in my setup it’s a moving target.
What I can share is the approach:
- pick a single source of truth for the public URL (for me it’s
asUrl(slug)) - pipe that into
alternates.canonical
My only advice: don’t ship canonicals you don’t fully trust.
A wrong canonical is worse than no canonical.
robots.ts (keep it boring)
This is the exact kind of thing I don’t want to maintain manually.
So I use the file convention:
import type { MetadataRoute } from "next";import { BASE_URL } from "@/config/constants";export default function robots(): MetadataRoute.Robots {return {rules: [{userAgent: "*",allow: "/",disallow: ["/api/", "/_next/"],},],sitemap: `${BASE_URL}/sitemap.xml`,};}
A couple tiny notes:
- I disallow
/api/because I have internal API routes (don’t waste crawl budget)
Things I’m intentionally not doing in metadata
Just to avoid mixing solutions:
- I’m not pushing OG image details into
generateMetadata() - I’m not building route handlers for OG images
I keep images handled by the opengraph-image file convention (covered in the previous post).
This post is strictly: titles, descriptions, canonicals, robots.