afaqrashid.com  ·  portfolio · 2026 ed.

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

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

Without 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-proto

There 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 headers

To 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

Sources