unhead vs react-helmet vs next/head: Document Head Management in JavaScript (2026)
TL;DR
unhead is the UnJS universal head manager — framework-agnostic, SSR/CSR, TypeScript-first, powers Nuxt's useHead() and works with any framework. react-helmet-async (successor to react-helmet) manages <head> tags in React — component-based API, SSR support, used widely in React SPAs. next/head is Next.js's built-in head component — simple API for managing <head> in Next.js pages, being replaced by the Metadata API in App Router. In 2026: unhead for framework-agnostic or Nuxt projects, Next.js Metadata API for Next.js App Router, react-helmet-async for React SPAs.
Key Takeaways
- unhead: ~3M weekly downloads — UnJS, framework-agnostic, SSR + CSR, full TypeScript types
- react-helmet-async: ~3M weekly downloads — React-specific, component-based head management
- next/head: built-in — Next.js Pages Router, being deprecated in favor of Metadata API
- Document head management handles
<title>,<meta>,<link>,<script>tags - Critical for SEO — search engines read meta tags from the document head
- SSR support is essential for meta tags to appear in the initial HTML response
The Problem
// Every page needs different head tags for SEO:
// <title>React vs Vue | PkgPulse</title>
// <meta name="description" content="Compare React and Vue..." />
// <meta property="og:title" content="React vs Vue" />
// <meta property="og:image" content="/og/react-vs-vue.png" />
// <link rel="canonical" href="https://pkgpulse.com/react-vs-vue" />
// Challenges:
// 1. Tags must be in <head>, but components render in <body>
// 2. Nested components may set conflicting titles
// 3. SSR must include tags in initial HTML (for SEO crawlers)
// 4. Tags must update when navigating between pages (SPA)
// 5. Deduplication — don't render duplicate meta tags
unhead
unhead — universal head management:
Basic usage
import { createHead, useHead } from "unhead"
// Create a head instance:
const head = createHead()
// Set head tags:
useHead({
title: "React vs Vue | PkgPulse",
meta: [
{ name: "description", content: "Compare React and Vue frameworks..." },
{ property: "og:title", content: "React vs Vue" },
{ property: "og:description", content: "Compare React and Vue frameworks..." },
{ property: "og:image", content: "https://pkgpulse.com/og/react-vs-vue.png" },
{ name: "twitter:card", content: "summary_large_image" },
],
link: [
{ rel: "canonical", href: "https://pkgpulse.com/react-vs-vue" },
],
})
Template params
import { useHead } from "unhead"
// Template params for consistent patterns:
useHead({
titleTemplate: "%s | PkgPulse",
templateParams: {
site: { name: "PkgPulse", url: "https://pkgpulse.com" },
},
})
// Per-page:
useHead({
title: "React vs Vue",
// Renders: "React vs Vue | PkgPulse"
})
// Override template:
useHead({
title: "PkgPulse — Compare npm Packages",
titleTemplate: null, // Skip template for homepage
})
SSR rendering
import { createHead, useHead, renderSSRHead } from "unhead"
// Server-side:
const head = createHead()
useHead({
title: "React vs Vue | PkgPulse",
meta: [
{ name: "description", content: "Compare frameworks..." },
{ property: "og:title", content: "React vs Vue" },
],
script: [
{ type: "application/ld+json", innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
headline: "React vs Vue",
}) },
],
})
// Render to HTML string:
const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } =
await renderSSRHead(head)
// Insert into HTML template:
const html = `
<!DOCTYPE html>
<html ${htmlAttrs}>
<head>
${headTags}
</head>
<body ${bodyAttrs}>
${bodyTagsOpen}
<div id="app">${appHtml}</div>
${bodyTags}
</body>
</html>
`
Vue integration
// @unhead/vue — Vue composables:
import { useHead, useSeoMeta } from "@unhead/vue"
// In a Vue component:
useSeoMeta({
title: "React vs Vue",
ogTitle: "React vs Vue | PkgPulse",
description: "Compare React and Vue frameworks...",
ogDescription: "Compare React and Vue frameworks...",
ogImage: "https://pkgpulse.com/og/react-vs-vue.png",
twitterCard: "summary_large_image",
})
// Reactive head:
const title = ref("Loading...")
useHead({
title: () => title.value, // Updates when ref changes
})
How Nuxt uses unhead
// Nuxt's useHead, useSeoMeta are powered by unhead:
// In a Nuxt page — auto-imported:
useSeoMeta({
title: "React vs Vue",
ogTitle: "React vs Vue | PkgPulse",
description: "Compare React and Vue frameworks...",
ogImage: "https://pkgpulse.com/og/react-vs-vue.png",
})
// nuxt.config.ts — global head:
export default defineNuxtConfig({
app: {
head: {
titleTemplate: "%s | PkgPulse",
link: [{ rel: "icon", href: "/favicon.ico" }],
},
},
})
react-helmet-async
react-helmet-async — React head management:
Setup
import { HelmetProvider, Helmet } from "react-helmet-async"
// Wrap app with provider:
function App() {
return (
<HelmetProvider>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/compare/:slug" element={<Compare />} />
</Routes>
</Router>
</HelmetProvider>
)
}
Component-based head
import { Helmet } from "react-helmet-async"
function ComparePage({ pkg1, pkg2 }) {
const title = `${pkg1} vs ${pkg2} | PkgPulse`
const description = `Compare ${pkg1} and ${pkg2} npm packages...`
return (
<>
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={`https://pkgpulse.com/og/${pkg1}-vs-${pkg2}.png`} />
<link rel="canonical" href={`https://pkgpulse.com/compare/${pkg1}-vs-${pkg2}`} />
</Helmet>
<div>
<h1>{pkg1} vs {pkg2}</h1>
{/* Page content */}
</div>
</>
)
}
SSR with react-helmet-async
import { HelmetProvider } from "react-helmet-async"
import { renderToString } from "react-dom/server"
// Server-side rendering:
const helmetContext = {}
const html = renderToString(
<HelmetProvider context={helmetContext}>
<App />
</HelmetProvider>
)
const { helmet } = helmetContext
const fullHtml = `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
${helmet.script.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<div id="root">${html}</div>
</body>
</html>
`
Nested head tags
// Parent component:
function Layout({ children }) {
return (
<>
<Helmet>
<title>PkgPulse</title>
<meta name="theme-color" content="#000000" />
</Helmet>
{children}
</>
)
}
// Child component — overrides parent title:
function PackagePage({ name }) {
return (
<>
<Helmet>
<title>{name} | PkgPulse</title>
{/* This title wins over parent's title */}
</Helmet>
<h1>{name}</h1>
</>
)
}
next/head
next/head — Next.js Pages Router head:
Basic usage (Pages Router)
import Head from "next/head"
export default function ComparePage({ pkg1, pkg2 }) {
return (
<>
<Head>
<title>{`${pkg1} vs ${pkg2} | PkgPulse`}</title>
<meta name="description" content={`Compare ${pkg1} and ${pkg2}...`} />
<meta property="og:title" content={`${pkg1} vs ${pkg2}`} />
<meta property="og:image" content={`/og/${pkg1}-vs-${pkg2}.png`} />
</Head>
<main>
<h1>{pkg1} vs {pkg2}</h1>
</main>
</>
)
}
Next.js Metadata API (App Router replacement)
// app/compare/[slug]/page.tsx — App Router:
import type { Metadata } from "next"
// Static metadata:
export const metadata: Metadata = {
title: "PkgPulse",
description: "Compare npm packages",
}
// Dynamic metadata:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = params
const [pkg1, pkg2] = slug.split("-vs-")
return {
title: `${pkg1} vs ${pkg2} | PkgPulse`,
description: `Compare ${pkg1} and ${pkg2} npm packages...`,
openGraph: {
title: `${pkg1} vs ${pkg2}`,
images: [`/og/${slug}.png`],
},
alternates: {
canonical: `https://pkgpulse.com/compare/${slug}`,
},
}
}
export default function ComparePage({ params }) {
return <h1>{params.slug}</h1>
}
Layout metadata
// app/layout.tsx — global metadata:
import type { Metadata } from "next"
export const metadata: Metadata = {
metadataBase: new URL("https://pkgpulse.com"),
title: {
default: "PkgPulse",
template: "%s | PkgPulse",
},
description: "Compare npm packages side by side",
openGraph: {
type: "website",
siteName: "PkgPulse",
},
twitter: {
card: "summary_large_image",
},
}
Feature Comparison
| Feature | unhead | react-helmet-async | next/head |
|---|---|---|---|
| Framework | Any | React | Next.js |
| API style | Composable/object | Component (JSX) | Component (JSX) |
| SSR support | ✅ | ✅ | ✅ |
| TypeScript | ✅ (full types) | ✅ | ✅ |
| Template params | ✅ | ❌ | ❌ (App Router has template) |
| Deduplication | ✅ | ✅ | ✅ |
| Reactive updates | ✅ | ✅ | ✅ |
| JSON-LD support | ✅ | ✅ | ✅ (App Router) |
| Used by | Nuxt | React SPAs | Next.js Pages Router |
| Status | Active | Active | Legacy (→ Metadata API) |
| Weekly downloads | ~3M | ~3M | Built-in |
When to Use Each
Use unhead if:
- Building with Nuxt or Vue
- Want a framework-agnostic head manager
- Need SSR with template params and full TypeScript
- Building a custom SSR framework
Use react-helmet-async if:
- Building a React SPA (Vite, CRA)
- Need component-based head management in React
- Using a React framework without built-in head management
- Need SSR with React's renderToString
Use Next.js Metadata API if:
- Building with Next.js App Router (preferred over next/head)
- Want zero-JS head management (server-rendered only)
- Need
generateMetadatafor dynamic pages
Use next/head if:
- Using Next.js Pages Router (legacy)
- Migrating gradually to App Router
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on unhead v1.x, react-helmet-async v2.x, and Next.js 15 Metadata API.