Skip to main content
ASoc
Tutorial

Next.js Head: next/head, the Metadata API, and Which One You Need

next/head is inert in the App Router. The metadata exports that replace it, and the missing export that un-prerendered 381 OG cards.

The ASoc Team9 min read

next/head is the Pages Router component for injecting tags into <head>. In the App Router it is inert — you export a metadata object or a generateMetadata function from a layout or page instead. This storefront runs Next.js 16.2.9 on the App Router: 27 routes, 28 files exporting metadata, zero next/head imports.

That last number is the useful part. Most "how do I use the Next.js head tag" answers are written for the router you are probably not on, and the two APIs fail differently when you get them wrong. Below is what each one does, how to tell which you have, and the three mistakes that cost this repo real build output.

The two eras

next/headMetadata API
RouterPages (pages/)App (app/)
ShapeA component you renderAn export the framework reads
WhereAny component in the treelayout.tsx / page.tsx only
Dynamic valuesProps, state, anythinggenerateMetadata(), async
Dedupes tags<title>, <base> automatically; others need keyMerged by the framework per field
Sets <html>/<body> attributesNoNo (write them in the layout's JSX)
Introduced / replacedSince Next 1.xMetadata API landed in 13.2

The split matters because a <Head> inside an App Router page is not an error. It renders nothing and reports nothing. If you added one and your <title> did not change, that is the whole explanation.

Which one you have, in one command

# App Router if this prints a path; Pages Router if it prints nothing.
ls src/app/layout.tsx app/layout.tsx 2>/dev/null

Both routers can coexist in one project, which is why "I have an app/ folder" is not a complete answer — a page under pages/ still uses next/head even when app/ exists beside it. The rule follows the file, not the project.

What the App Router version looks like here

The root layout sets the site-wide defaults once. The two fields worth copying are metadataBase and the title template:

// src/app/layout.tsx (abridged)
export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  title: {
    default: "ASoc — Production-ready Tailwind CSS templates",
    template: "%s — ASoc",
  },
  description:
    "Production-ready Tailwind CSS templates for React, Next.js, Vue, Angular, and HTML …",
};

metadataBase is the one that saves you later: with it set, every relative alternates.canonical and openGraph.url in the app resolves to an absolute URL. Without it, Next.js warns and falls back to localhost in development, which is how relative canonicals end up shipping as http://localhost:3000/.... Canonical specifics are in Next.js canonical URL.

template: "%s — ASoc" appends the site name to every child title. That is convenient until a page's title already ends in the brand — see the next section.

Dynamic metadata: generateMetadata

For the 111 product pages the title and description are computed per slug. This is the real function from the repo, not a reduced one:

// src/app/templates/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { slug } = await params;
  const product = getProduct(slug);
  if (!product) return { title: "Template not found" };
  const title = productTitle(product);
  const description = productMetaDescription(product);
  return {
    robots:
      product.status === "available"
        ? undefined
        : { index: false, follow: true },
    title: { absolute: title },
    description,
    alternates: { canonical: `/templates/${slug}` },
    openGraph: {
      type: "website",
      siteName: "ASoc",
      locale: "en_US",
      url: `/templates/${slug}`,
      title,
      description,
      images: [product.screenshots[0]],
    },
  };
}

Three decisions in there are the ones people hit:

title: { absolute: title } opts out of the parent template. These titles already end in the product name, which begins with "ASoc", so the inherited "%s — ASoc" would have produced "… ASoc Vault — ASoc". absolute is the escape hatch, and it is per-page rather than a config change.

openGraph replaces the parent's object — it does not merge into it. That is why type, siteName and locale are re-declared here even though the root layout already sets them. Returning only images from a child silently drops the inherited og:site_name. The same trap applies to twitter.

robots is computed, not constant. A coming-soon product is a placeholder screenshot with nothing to buy, so it ships index: false, follow: true — crawlers skip the thin page but still follow its links. Setting status: "available" restores indexing and the sitemap entry together; there is no second switch.

params is a Promise here, which is the Next 15+ shape. If your generateMetadata destructures params directly and TypeScript complains, that is the change you are looking at.

The defect worth copying: metadata routes need their own params

This one cost real build output. A file-based metadata route such as opengraph-image.tsx is its own route, even when it sits under a dynamic segment whose page.tsx already declares generateStaticParams. It does not inherit them.

// src/app/blog/[slug]/opengraph-image.tsx
export const alt = "An article on the ASoc blog";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

// A metadata route under a dynamic segment is its own route, so it needs its
// own params even though the page beside it already declares them. Without
// this the card is rendered on demand per request (`ƒ` in the build output)
// instead of being baked at build time like every other page on the site.
export function generateStaticParams() {
  return getPublishedSlugs().map((slug) => ({ slug }));
}

The symptom is quiet: the cards still work, so nothing looks broken. The only signal is the build summary, where that route shows as a function (ƒ) rather than prerendered — meaning every social-card fetch runs Satori on demand instead of serving a static PNG. On a blog this size that is hundreds of routes moving off the static path because one export was missing. Generating the cards themselves is covered in Next.js Open Graph image generation.

Audit yours by reading the build output rather than the browser:

npm run build | grep -i "opengraph\|○\|●\|ƒ"

If you are on the Pages Router

next/head is not deprecated for pages/, and the component is unremarkable:

import Head from "next/head";

export default function Page() {
  return (
    <>
      <Head>
        <title>Pricing — ASoc</title>
        <meta name="description" content="Four tiers, one-time payment." />
        <link rel="canonical" href="https://example.com/pricing" />
      </Head>
      <main>…</main>
    </>
  );
}

Four rules carry most of the trouble:

  1. You may render more than one <Head>, and nested ones merge. <title> and <base> are deduped by tag name, so they never need a key.
  2. <meta> is deduped only on four props, and property is not one of them. The dedupe list in next/head is name, httpEquiv, charSet and itemProp, so two <meta name="description"> tags collapse to one — but Open Graph tags, which use property="og:…", do not. That is the real reason duplicate og:title tags are the classic next/head bug, and why OG tags specifically want a stable key.
  3. You cannot set <html> or <body> attributes through it. next/head only manages children of <head>; lang belongs in _document.tsx.
  4. Do not put the next/script component inside <Head>. Next.js errors on that pairing; <Script> goes in the body with a strategy. See Next.js script.

A plain <head> element written by hand is a separate mistake — the @next/next/no-head-element lint rule exists precisely to catch it, because hand-written head markup bypasses the deduplication both APIs depend on.

Migrating a <Head> to metadata

The mapping is mechanical for everything except conditional logic:

In <Head>As metadata
<title>X</title>title: "X" or title: { absolute: "X" }
<meta name="description">description: "…"
<link rel="canonical">alternates: { canonical: "/path" }
<meta property="og:*">openGraph: { … }
<meta name="twitter:*">twitter: { … }
<meta name="robots">robots: { index, follow }
<meta name="viewport">export const viewport (separate export)
JSON-LD <script>Stays a <script> tag in the component

The last row is the one that surprises people. Structured data has no metadata field; you render a <script type="application/ld+json"> in the component body, which is exactly what this repo's product pages do for their Product schema. Next.js JSON-LD covers the escaping details.

viewport and themeColor moved out of metadata into their own viewport export — leaving them in metadata logs a deprecation warning rather than failing, so it is easy to miss.

Troubleshooting

SymptomCauseFix
<Head> renders but the title never changesIt is in an App Router file, where it is inertExport metadata / generateMetadata instead
Title reads "Page — Site — Site"Child title ran through the parent's templateUse title: { absolute }
og:site_name disappears on one routeA child openGraph replaced the parent's objectRe-declare the shared OG fields in the child
Canonicals ship as localhostmetadataBase unsetSet it in the root layout
Duplicate og: tags (Pages Router)OG uses property, which next/head does not dedupeAdd a stable key per OG tag
OG image route shows ƒ in the buildMetadata route missing generateStaticParamsAdd it to the opengraph-image file
params type error in generateMetadataparams is a Promise in Next 15+await params

Frequently asked questions

Is next/head deprecated? Not for the Pages Router, where it remains the supported API. It has no effect in the App Router, which is a different thing from deprecation — there is no warning, it simply does nothing.

Can I use next/head in a Client Component in the App Router? No. "use client" does not bring it back; the App Router collects head tags from metadata exports only, and those cannot live in a Client Component.

How do I set metadata that depends on data I fetch? generateMetadata is async, so fetch inside it. Requests are deduplicated against the page's own fetches of the same resource, so the data is not fetched twice.

Do I need metadataBase if all my URLs are absolute? No, but it is two lines and it makes every relative URL in the app correct by default. This repo sets it once and then writes relative canonicals everywhere.

Templates in this post

ASoc Vault is a fintech SaaS marketing site with unified accounts, FX and a tiered pricing page, ASoc Vox is an AI-voiceover site built around an eight-capability voice engine, and ASoc Weave is an AI website-builder landing page with a 3-step how-it-works flow. All three are App Router projects, so each page's title and description come from a metadata export — there is no next/head to migrate.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial13 min read

Next.js Image Optimization Without next/image: 255 KiB to 33 KiB

A closed image set does not need a request-time optimiser. The sharp build script, the plain-img markup, and the lazy LCP image that cost us 1.67s of load delay.

Read more
Tutorial8 min read

Next.js Intercepting Routes: Why This Codebase Has Zero

Intercepting routes mask navigation to a route in your own app. This storefront's live-preview modal shows a cross-origin iframe instead — a real reason the pattern never fit here.

Read more