Next.js generateMetadata: Get the Current URL, Tested
generateMetadata receives params and searchParams, but not the pathname, and usePathname only works in client components. Most people want the URL for one reason: to set the canonical or og:url. For that, Next.js has a built-in answer that keeps the page static, plus one trap.
I tested five ways on Next.js 16.4.4 with next build and next start, with metadataBase set to https://example.com in the root layout, and read the tags with curl.
1. A relative "./" canonical
// app/docs/[slug]/page.tsx
export const metadata: Metadata = {
alternates: { canonical: "./" },
openGraph: { url: "./" },
};/docs/intro canonical https://example.com/docs/intro
/docs/intro?utm=1 canonical https://example.com/docs/intro
/docs/other canonical https://example.com/docs/other
/rel/sub canonical https://example.com/rel/sub og:url https://example.com/rel/subNext.js resolves a value that starts with ./ against the current route path, then adds metadataBase. It worked for a slug from generateStaticParams, for a slug that wasn't, and on a plain static page. The query string was dropped, which is what you want from a canonical. With trailingSlash: true it gave /docs/intro/ and /rel/sub/, matching the URLs that return 200.
It is a plain metadata object, not generateMetadata, and the build kept every one of these pages static: ○ for /rel/sub and ● for /docs/intro.
The trap: "." is not "./"
canonical: "." → https://example.com (the home page)
canonical: "./" → https://example.com/odd/abcWithout the slash, every page using it pointed its canonical at the home page, with no warning at build time. In the Next.js source, resolveRelativeUrl only resolves strings that start with ./ against the pathname. Anything else is joined to metadataBase as a path, and "." becomes the root.
2. params, for dynamic segments
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
return { alternates: { canonical: `/blog/${slug}` } };
}This gave https://example.com/blog/post-1 and the page stayed static with generateStaticParams. Use it when the canonical isn't the URL the page was reached on, for example when two slugs show the same post and one should win.
3. headers(): host, but no path
I printed every request header that generateMetadata could read:
accept, host, user-agent, x-forwarded-for,
x-forwarded-host, x-forwarded-port, x-forwarded-protoThere is no path header. You get the host, and calling headers() turned the page dynamic (ƒ), so it renders on every request.
The host also isn't safe to trust. When I sent my own x-forwarded-host: www.example.com header to next start, generateMetadata read www.example.com. A canonical built from it says whatever the request says. Build absolute URLs from metadataBase, which you set, not from request headers.
4. A header set in proxy.ts
// proxy.ts
export function proxy(request: NextRequest) {
const headers = new Headers(request.headers);
headers.set("x-pathname", request.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
}
// page.tsx
const path = (await headers()).get("x-pathname");This returned the right path, /px. The page still has to call headers() to read it, so it was dynamic too, and the proxy now runs on every request it matches. It only makes sense if you need the path for something other than the canonical.
5. searchParams
export async function generateMetadata({ searchParams }: {
searchParams: Promise<Record<string, string | undefined>>;
}): Promise<Metadata> {
const { page } = await searchParams;
return { alternates: { canonical: page ? `/sp?page=${page}` : "/sp" } };
}/sp?page=2&utm_source=x gave a canonical of https://example.com/sp?page=2, because I built it from the one parameter that changes the content. Reading searchParams made the page dynamic. Don't copy the whole query string into a canonical, or every tracking parameter becomes its own URL.
Which to use
Canonical or og:url for the URL itself "./" stays static
Canonical that differs from the URL params stays static
Pagination or filter in the canonical searchParams dynamic
Path for something else proxy header dynamic
Host metadataBase don't read headersTo check a page, open the live HTML and look for the canonical with the free Next.js metadata checker, or run curl -s https://your-site.com/some/page | grep canonical. If every page shows your home page URL, look for a "." without the slash.
Related
- Next.js canonical URL: 4 traps I tested and the fix
- Next.js generateMetadata fetching twice? What I tested
- Next.js metadata not working? 7 causes I tested
- Next.js trailingSlash: why your sitemap URLs redirect
- Next.js metadata checker