Skip to main content

unhead vs react-helmet vs next/head: Document Head Management in JavaScript (2026)

·PkgPulse Team

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

Featureunheadreact-helmet-asyncnext/head
FrameworkAnyReactNext.js
API styleComposable/objectComponent (JSX)Component (JSX)
SSR support
TypeScript✅ (full types)
Template params❌ (App Router has template)
Deduplication
Reactive updates
JSON-LD support✅ (App Router)
Used byNuxtReact SPAsNext.js Pages Router
StatusActiveActiveLegacy (→ Metadata API)
Weekly downloads~3M~3MBuilt-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 generateMetadata for 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.

Compare SEO tools and developer utilities on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.