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.
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/head | Metadata API | |
|---|---|---|
| Router | Pages (pages/) | App (app/) |
| Shape | A component you render | An export the framework reads |
| Where | Any component in the tree | layout.tsx / page.tsx only |
| Dynamic values | Props, state, anything | generateMetadata(), async |
| Dedupes tags | <title>, <base> automatically; others need key | Merged by the framework per field |
Sets <html>/<body> attributes | No | No (write them in the layout's JSX) |
| Introduced / replaced | Since Next 1.x | Metadata 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:
- You may render more than one
<Head>, and nested ones merge.<title>and<base>are deduped by tag name, so they never need akey. <meta>is deduped only on four props, andpropertyis not one of them. The dedupe list innext/headisname,httpEquiv,charSetanditemProp, so two<meta name="description">tags collapse to one — but Open Graph tags, which useproperty="og:…", do not. That is the real reason duplicateog:titletags are the classicnext/headbug, and why OG tags specifically want a stablekey.- You cannot set
<html>or<body>attributes through it.next/headonly manages children of<head>;langbelongs in_document.tsx. - Do not put the
next/scriptcomponent inside<Head>. Next.js errors on that pairing;<Script>goes in the body with astrategy. 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
| Symptom | Cause | Fix |
|---|---|---|
<Head> renders but the title never changes | It is in an App Router file, where it is inert | Export metadata / generateMetadata instead |
| Title reads "Page — Site — Site" | Child title ran through the parent's template | Use title: { absolute } |
og:site_name disappears on one route | A child openGraph replaced the parent's object | Re-declare the shared OG fields in the child |
Canonicals ship as localhost | metadataBase unset | Set it in the root layout |
Duplicate og: tags (Pages Router) | OG uses property, which next/head does not dedupe | Add a stable key per OG tag |
OG image route shows ƒ in the build | Metadata route missing generateStaticParams | Add it to the opengraph-image file |
params type error in generateMetadata | params 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.
