Skip to main content

Guide

unhead vs react-helmet vs next/head 2026

Compare unhead, react-helmet, and next/head for managing HTML document head tags in JavaScript. SEO meta tags, SSR support, title management, and which to use.

·PkgPulse Team·
0

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

JSON-LD Structured Data and Rich Results

Managing JSON-LD structured data (<script type="application/ld+json">) is one of the more error-prone aspects of document head management. The content must be valid JSON serialized inside a <script> tag, the data must match the page content, and it must be present in the server-rendered HTML for Google to process it. Each library handles this differently.

Next.js App Router's Metadata API doesn't have a first-class JSON-LD field — you inject it via <script> tags in the page component itself rather than through the generateMetadata function. The recommended pattern in Next.js docs is to add a <script type="application/ld+json"> element directly in the JSX, which works but sits outside the metadata system. unhead has explicit JSON-LD support: you pass a script object with type: "application/ld+json" and innerHTML: JSON.stringify(schema), and unhead handles serialization and deduplication. The @unhead/schema-org plugin goes further, providing typed schema.org constructors so you get TypeScript autocomplete for @type: "Article", @type: "Product", and other schema types rather than building raw objects.

react-helmet-async handles JSON-LD via the <script type="application/ld+json"> element inside <Helmet>, serializing the JSON string with {JSON.stringify(schema)}. The main risk is double-serialization — if the component re-renders and Helmet sees a new string reference (even with identical data), it may briefly flash the old script before replacing it. Memoizing the schema object with useMemo prevents this. For SPAs where structured data changes with navigation (an article page schema on the blog, a product schema on the shop page), react-helmet-async's component-based model ensures the correct schema loads with each page component.

SSR Hydration and the Title Flicker Problem

A common issue with client-side head management is the title flicker: the server renders <title>React vs Vue | PkgPulse</title> in the initial HTML, but after JavaScript loads and the SPA's router takes control, there's a brief window where the browser title shows either a stale value or nothing. This is visible in browser tabs during navigation and can affect analytics tools that record page titles.

react-helmet-async's HelmetProvider uses a server-side context object during SSR that captures the head state, then rehydrates it on the client without re-applying tags (they're already in the DOM from SSR). This means there's no flicker on initial load, but navigating to a new route still triggers a client-side Helmet update. The update is synchronous with React's render cycle, so the title change happens as part of the component mount — fast enough that users don't perceive it as a flicker.

unhead's Vue composables use Vue's reactivity system to keep head state synchronized with component data. When a route change triggers a new useHead() call, unhead diffs the previous head state against the new one and applies only the changes — similar to how React reconciles the virtual DOM. This diff-based approach means navigating between two pages that share the same meta description doesn't cause a DOM mutation for that tag, reducing unnecessary work.

Next.js App Router's Metadata API runs entirely on the server — generateMetadata executes during SSR and the resulting tags are emitted as part of the HTML. There's no client-side head update at all during navigation because App Router uses React Server Components for pages, which re-render on the server. The result is zero title flicker and perfect SEO tag delivery, at the cost of requiring a server round-trip for each navigation.

The Next.js Migration Path: next/head to Metadata API

Teams upgrading Next.js apps from the Pages Router to the App Router face the task of migrating <Head> component usage to the generateMetadata / metadata export pattern. The migration is conceptually straightforward — move head content from JSX into exported objects — but requires changing the data flow. Pages Router pages often call getServerSideProps to fetch page-specific data, then pass it to <Head> through props. In App Router, generateMetadata receives the same params and searchParams that the page component receives and can call the same data-fetching functions directly.

The key behavior change is deduplication. next/head deduplicates tags by their content at render time — if a parent Layout and a child Page both set <meta name="description">, the last one wins. The Metadata API merges metadata objects through the layout hierarchy with a defined priority order: page-level metadata overrides layout metadata for the same key. This is more predictable than next/head's implicit deduplication and makes it easier to understand exactly which tags appear in the final output.


Open Graph and Twitter Card Testing

The practical challenge with head tag management is verifying that Open Graph and Twitter Card tags actually appear correctly in shared previews. All three libraries emit the correct HTML in server-rendered output, but preview validators like the Facebook Sharing Debugger, LinkedIn Post Inspector, and Twitter Card Validator cache aggressively. After deploying changes to Open Graph tags, you typically need to explicitly clear the cache in each validator before the new tags appear in preview. Tools like opengraph.io and metatags.io fetch pages live without caching, making them useful for development verification. The most common mistake when migrating from next/head to the App Router Metadata API is missing the metadataBase URL — without it, relative openGraph.images paths resolve to localhost in production, breaking link preview images. Verifying Open Graph output in staging with curl -A "facebookexternalhit" before deploying is a quick sanity check that catches this class of bug before it affects shared links.

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 →

See also: React vs Vue and React vs Svelte, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.