Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(nuxt): migrate to unhead v3#34793

Open
harlan-zw wants to merge 20 commits intomainnuxt/nuxt:mainfrom
feat/unhead-v3nuxt/nuxt:feat/unhead-v3Copy head branch name to clipboard
Open

feat(nuxt): migrate to unhead v3#34793
harlan-zw wants to merge 20 commits intomainnuxt/nuxt:mainfrom
feat/unhead-v3nuxt/nuxt:feat/unhead-v3Copy head branch name to clipboard

Conversation

@harlan-zw
Copy link
Copy Markdown
Contributor

@harlan-zw harlan-zw commented Apr 10, 2026

🔗 Linked issue

Related to unhead v3 release (https://unhead.unjs.io/docs/releases/v3)

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • ⚠️ Breaking change

📚 Description

Unhead v3 is out, and we plan to have Nuxt v5 fully adopt it and all of its features (namely the Vite plugin / devtools). To make the migration smoother and to keep Nuxt using the latest (and to unblock streaming #34411), we want the next Nuxt minor to use Unhead v3.

Nuxt v4.5.0

  • Better performance (bit smaller / sync)
  • Better type safety out of the box for their useHead().

Potential Issues

  • ⚠️ In v3 we implement type-narrowing for useHead() meaning potential breaking type changes for end users. We'd likely need something to help make the migration easier. I'm working on a Unhead CLI that can run a one-time migration to help with this. Decide: are type-breaking changes acceptable for minors?
  • ⚠️ Unhead goes from an async to sync internal engine. Any ecosystem dependencies relying on async hooks will break. To the best of my knowledge, this should be quite edge case and we deprecated promise input in v2.

Nuxt v5

  • Adopts Vite plugin (several build-time optimizations useSeoMeta -> useHead) and Vite Devtools
  • Validation plugin (warns users when they have incorrect head tags)
  • Minify Transform for Vite users, we use lightningcss / rolldown to minify any static <style> or <script> tags.
  • ⚠️ useServer* components are dropped
  • ⚠️ Template params plugin is not installed by default

Migration

For compat v4 users, no runtime code changes should be required for typical head usage. TS errors from the narrowed unions can be fixed incrementally with defineLink / defineScript / defineMeta wrappers at the call site.

For compat v5 users, replace deprecated composables and patterns:

- useServerSeoMeta({ description: '...' })
+ if (import.meta.server) {
+   useSeoMeta({ description: '...' })
+ }

- useServerHead({ title: '...' })
+ if (import.meta.server) {
+   useHead({ title: '...' })
+ }

- { hid: 'description', name: 'description', content: '...' }
+ { key: 'description', name: 'description', content: '...' }

- { children: 'console.log(\"hello\")' }
+ { innerHTML: 'console.log(\"hello\")' }

- { src: '/script.js', body: true }
+ { src: '/script.js', tagPosition: 'bodyClose' }

- Bump @unhead/vue and unhead to 3.0.1
- Remove v2 `mode: 'server'` entry options (removed in v3)
- Make renderSSRHead/renderDOMHead synchronous (v3 sync rendering)
- Clean up importmap: auto-serialize innerHTML, rely on capo weight 25
- Clean up speculationrules: use defineScript, auto-serialize innerHTML
- Use defineLink for strict type narrowing in payload composable
- Fix strict type issues in head components (v3 discriminated unions)
- Add new unhead config options: validate, canonical, minify, templateParams
- Gate legacy mode and deprecated composables on compatibilityVersion >= 5
- Wire ValidatePlugin (dev), CanonicalPlugin, MinifyPlugin into module
- Remove useServerHead/useServerHeadSafe/useServerSeoMeta from v5 auto-imports
- Update test fixtures and composables test expectations
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@github-actions github-actions Bot added agentscan:mixed-signals 5.x ✨ enhancement New feature or improvement to existing functionality labels Apr 10, 2026
@socket-security
Copy link
Copy Markdown

socket-security Bot commented Apr 10, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​unhead/​vue@​2.1.12 ⏵ 3.0.210010010096 +1100
Updated@​unhead/​vue@​2.1.12 ⏵ 3.0.310010010096 +1100

View full report

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 10, 2026

Open in StackBlitz

@nuxt/kit

npm i https://pkg.pr.new/@nuxt/kit@34793

@nuxt/nitro-server

npm i https://pkg.pr.new/@nuxt/nitro-server@34793

nuxt

npm i https://pkg.pr.new/nuxt@34793

@nuxt/rspack-builder

npm i https://pkg.pr.new/@nuxt/rspack-builder@34793

@nuxt/schema

npm i https://pkg.pr.new/@nuxt/schema@34793

@nuxt/vite-builder

npm i https://pkg.pr.new/@nuxt/vite-builder@34793

@nuxt/webpack-builder

npm i https://pkg.pr.new/@nuxt/webpack-builder@34793

commit: 676a4f6

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 10, 2026

Merging this PR will degrade performance by 21.51%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 19 untouched benchmarks
⏩ 3 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
writeTypes in the basic-types fixture 38.8 ms 49.5 ms -21.51%

Comparing feat/unhead-v3 (676a4f6) with main (941d2c9)2

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (96b14dd) during the generation of this report, so 941d2c9 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

…@unhead/vue

3.0.2 re-exports defineLink/defineScript from @unhead/vue, removing the
need for a direct `unhead` dependency. Switch imports accordingly and
drop `unhead` from package.json deps.
…ime plugins

- Remove CanonicalPlugin, ValidatePlugin, MinifyPlugin runtime registration for v5
- Register @unhead/vue/vite Unhead() build plugin when compatibilityVersion >= 5
- Configure rolldown/lightningcss minifiers by default
- Expose `unhead.vite` config key for full vite plugin customization
- Simplify unhead config to legacy, vite, renderSSRHeadOptions
…/vue/legacy

Replaces the hand-rolled v4 compat plugin list (TemplateParamsPlugin,
AliasSortingPlugin, DeprecationsPlugin) with the canonical `legacyPlugins`
export from `@unhead/vue/legacy` shipped in 3.0.3.

The v4 compat set now also includes `PromisesPlugin`, which is a no-op when
no promise values are passed. This aligns Nuxt's v4 default with unhead's
published "v2 compat set" and lets `legacy: true` stay focused on the CAPO
opt-out (plus the new deprecation warning).
Adds @deprecated JSDoc markers so IDEs flag both options at their usage
sites. Both still function: `legacy: true` continues to disable CAPO
sorting on compat v4, and `headNext: false` still forces the legacy sort
path. Runtime warnings (routed through useLogger('nuxt:unhead')) surface
at build time so users have a clear migration signal before the options
are removed.
…ed options

The unhead module itself reads `options.legacy` and `experimental.headNext`
to emit migration warnings and to compute `disableCapoSorting`. ESLint's
no-deprecated rule flags these as consumer usages, so hoist them into
local variables with targeted disables that explain why the read is
intentional.
…mpat v4

Compat v5 gets tree-shaking via the `@unhead/vue/vite` plugin registered
later in setup, but v4 users never see that plugin. Re-add the Nuxt
composable tree-shaker registration gated on v4 + production so
`useServerHead` / `useServerSeoMeta` / `useServerHeadSafe` still get
stripped from the client bundle.
Template interpolation (%s, %siteName, %separator) in titleTemplate and
SEO meta values is a core Nuxt idiom and was unhead v2 default behavior.
Dropping it in the legacyPlugins refactor broke SSR for fixtures that rely
on templateParams, including the basic fixture's head-script-setup page.

Restore TemplateParamsPlugin unconditionally on v5 while keeping the full
legacyPlugins set for v4.
@danielroe
Copy link
Copy Markdown
Member

@harlan-zw what are we waiting on for this? anything I can help with?

if it helps, main is v5 and I normally cherry-pick features back to 4.x, or we could simply make a separate PR to 4.x if that would be better....

@harlan-zw
Copy link
Copy Markdown
Contributor Author

harlan-zw commented Apr 20, 2026

@harlan-zw what are we waiting on for this? anything I can help with?

if it helps, main is v5 and I normally cherry-pick features back to 4.x, or we could simply make a separate PR to 4.x if that would be better....

I just need to check carefully that we're not introducing behavior regressions for v4 outside of the type narrowing. I'll aim to wrap it up later this evening or tomorrow.

@harlan-zw harlan-zw marked this pull request as ready for review April 21, 2026 01:26
@harlan-zw harlan-zw requested a review from danielroe as a code owner April 21, 2026 01:26
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

Walkthrough

This pull request updates Nuxt's Unhead integration to introduce compatibility version-aware configuration and deprecate server-scoped head composables. Changes include removing explicit mode: 'server' options from head API calls, converting async SSR head rendering to synchronous operations, adding a new Vite plugin integration for Unhead v5 with rolldown and lightningcss support, and updating head entry definitions to use Unhead's defineLink and defineScript helpers. Schema configuration adds a new unhead.vite option for versions >= 5, while existing unhead.legacy functionality is gated behind compatibility version checks. Test fixtures and composables are updated to use shared head composables instead of server-scoped variants.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(nuxt): migrate to unhead v3' accurately reflects the primary change, which is migrating Nuxt to Unhead v3 across multiple files and configuration.
Description check ✅ Passed The description provides comprehensive context on the Unhead v3 migration, including objectives, migration guidance, and potential issues relevant to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/unhead-v3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/nuxt/src/head/runtime/components.ts (1)

333-340: ⚠️ Potential issue | 🟡 Minor

Guard <Style> slot content with type check instead of truthiness check.

The current code uses a truthiness guard followed by an unsafe as string cast, which allows two issues:

  1. Non-string values pass through to textContent at runtime in production builds (the dev-time check is insufficient).
  2. Empty string slots are skipped entirely, which can leave stale CSS if a slot changes from non-empty to empty.

The input.style![idx] = style assignment is also inside the conditional block, so it never executes for falsy or non-string content.

Replace the truthiness check with a type guard to ensure only strings are assigned and the style entry is always recorded:

Suggested fix
       const style = normalizeProps(props, key) as UnheadStyle
       const textContent = slots.default?.()?.[0]?.children
-      if (textContent) {
-        if (import.meta.dev && typeof textContent !== 'string') {
+      if (typeof textContent === 'string') {
+        style.textContent = textContent
+      } else if (import.meta.dev && textContent != null) {
           console.error('<Style> can only take a string in its default slot.')
-        }
-        input.style![idx] = style
-        style.textContent = textContent as string
       }
+      input.style![idx] = style
       update()
       return null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxt/src/head/runtime/components.ts` around lines 333 - 340, Replace
the truthiness guard on the slot value with a proper type check: compute
textContent from slots.default?.()?.[0]?.children, then always set
input.style![idx] = style (so the style entry is recorded) and set
style.textContent to the string only when typeof textContent === 'string'; if
it's not a string set style.textContent to an empty string (or undefined) to
avoid stale CSS. Keep the import.meta.dev console.error path to warn in dev when
typeof textContent !== 'string'. Update the logic around
normalizeProps/UnheadStyle, slots.default, input.style, style.textContent and
idx accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/schema/src/config/app.ts`:
- Around line 176-182: The resolver for the legacy config currently coerces a
user-set boolean to false when future.compatibilityVersion >= 5, which hides the
user-provided flag; update the legacy $resolve in
packages/schema/src/config/app.ts so that when typeof val === 'boolean' it
returns val (preserving the user-provided true/false) and only defaults to false
when val is not a boolean — keep the get('future.compatibilityVersion') check
out of the coercion so module.ts can detect the original user flag.

---

Outside diff comments:
In `@packages/nuxt/src/head/runtime/components.ts`:
- Around line 333-340: Replace the truthiness guard on the slot value with a
proper type check: compute textContent from slots.default?.()?.[0]?.children,
then always set input.style![idx] = style (so the style entry is recorded) and
set style.textContent to the string only when typeof textContent === 'string';
if it's not a string set style.textContent to an empty string (or undefined) to
avoid stale CSS. Keep the import.meta.dev console.error path to warn in dev when
typeof textContent !== 'string'. Update the logic around
normalizeProps/UnheadStyle, slots.default, input.style, style.textContent and
idx accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b4285ffe-efca-4603-a31a-8e5dbd1e6e50

📥 Commits

Reviewing files that changed from the base of the PR and between 96b14dd and 676a4f6.

⛔ Files ignored due to path filters (5)
  • package.json is excluded by !package.json, !**/package.json
  • packages/nitro-server/package.json is excluded by !**/package.json
  • packages/nuxt/package.json is excluded by !**/package.json
  • packages/schema/package.json is excluded by !**/package.json
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !pnpm-lock.yaml
📒 Files selected for processing (12)
  • packages/nitro-server/src/runtime/handlers/island.ts
  • packages/nitro-server/src/runtime/handlers/renderer.ts
  • packages/nuxt/build.config.ts
  • packages/nuxt/src/app/composables/payload.ts
  • packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts
  • packages/nuxt/src/head/module.ts
  • packages/nuxt/src/head/runtime/components.ts
  • packages/nuxt/src/head/runtime/plugins/unhead.ts
  • packages/schema/src/config/app.ts
  • packages/schema/src/types/schema.ts
  • test/fixtures/basic/app/pages/head-script-setup.vue
  • test/nuxt/composables.test.ts

Comment on lines +176 to +182
legacy: {
$resolve: async (val, get) => {
if (typeof val === 'boolean') {
return (await get('future.compatibilityVersion') as number) >= 5 ? false : val
}
return false
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve unhead.legacy so v5 users still get the warning.

Line 179 coerces unhead.legacy: true to false for compatibility v5, so packages/nuxt/src/head/module.ts Lines 105-110 cannot detect the user-provided flag and the “ignored in v5” migration warning becomes unreachable.

Proposed fix
     legacy: {
-      $resolve: async (val, get) => {
+      $resolve: (val) => {
         if (typeof val === 'boolean') {
-          return (await get('future.compatibilityVersion') as number) >= 5 ? false : val
+          return val
         }
         return false
       },
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
legacy: {
$resolve: async (val, get) => {
if (typeof val === 'boolean') {
return (await get('future.compatibilityVersion') as number) >= 5 ? false : val
}
return false
},
legacy: {
$resolve: (val) => {
if (typeof val === 'boolean') {
return val
}
return false
},
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/schema/src/config/app.ts` around lines 176 - 182, The resolver for
the legacy config currently coerces a user-set boolean to false when
future.compatibilityVersion >= 5, which hides the user-provided flag; update the
legacy $resolve in packages/schema/src/config/app.ts so that when typeof val ===
'boolean' it returns val (preserving the user-provided true/false) and only
defaults to false when val is not a boolean — keep the
get('future.compatibilityVersion') check out of the coercion so module.ts can
detect the original user flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

5.x ✨ enhancement New feature or improvement to existing functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.