diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 0e0aa726aad..407a18c71e9 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -81,6 +81,7 @@
"combinate": "^1.1.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "unhead": "^2.0.8",
"zod": "^3.24.2"
},
"peerDependencies": {
diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx
index 638dee8acc3..d5aa1c784cb 100644
--- a/packages/react-router/src/HeadContent.tsx
+++ b/packages/react-router/src/HeadContent.tsx
@@ -4,6 +4,33 @@ import { useRouter } from './useRouter'
import { useRouterState } from './useRouterState'
import type { RouterManagedTag } from '@tanstack/router-core'
+const isTruthy = (val?: string | boolean) => val === '' || val === true
+const WEIGHT_MAP = {
+ meta: {
+ 'content-security-policy': -30,
+ charset: -20,
+ viewport: -15,
+ },
+ link: {
+ preconnect: 20,
+ stylesheet: 60,
+ preload: 70,
+ modulepreload: 70,
+ prefetch: 90,
+ 'dns-prefetch': 90,
+ prerender: 90,
+ },
+ script: {
+ async: 30,
+ defer: 80,
+ sync: 50,
+ },
+ style: {
+ imported: 40,
+ sync: 60,
+ },
+} as const
+
export const useTags = () => {
const router = useRouter()
@@ -133,9 +160,61 @@ export const useTags = () => {
*/
export function HeadContent() {
const tags = useTags()
- return tags.map((tag) => (
-
- ))
+ return tags
+ .map(weightTags)
+ .sort((a, b) => a.weight - b.weight)
+ .map((tag) => )
+}
+
+function weightTags(tag: RouterManagedTag) {
+ let weight = 100
+
+ if (tag.tag === 'title') {
+ weight = 10
+ } else if (tag.tag === 'meta') {
+ const metaType =
+ tag.attrs?.httpEquiv === 'content-security-policy'
+ ? 'content-security-policy'
+ : tag.attrs?.charSet
+ ? 'charset'
+ : tag.attrs?.name === 'viewport'
+ ? 'viewport'
+ : null
+
+ if (metaType) {
+ weight = WEIGHT_MAP.meta[metaType]
+ }
+ } else if (tag.tag === 'link' && tag.attrs?.rel) {
+ weight =
+ tag.attrs.rel in WEIGHT_MAP.link
+ ? WEIGHT_MAP.link[tag.attrs.rel as keyof typeof WEIGHT_MAP.link]
+ : weight
+ } else if (tag.tag === 'script') {
+ if (isTruthy(tag.attrs?.async)) {
+ weight = WEIGHT_MAP.script.async
+ } else if (
+ tag.attrs?.src &&
+ !isTruthy(tag.attrs.defer) &&
+ !isTruthy(tag.attrs.async) &&
+ tag.attrs.type !== 'module' &&
+ !tag.attrs.type?.endsWith('json')
+ ) {
+ weight = WEIGHT_MAP.script.sync
+ } else if (
+ isTruthy(tag.attrs?.defer) &&
+ tag.attrs.src &&
+ !isTruthy(tag.attrs.async)
+ ) {
+ weight = WEIGHT_MAP.script.defer
+ }
+ } else if (tag.tag === 'style') {
+ weight = tag.children ? WEIGHT_MAP.style.imported : WEIGHT_MAP.style.sync
+ }
+
+ return {
+ ...tag,
+ weight,
+ }
}
function uniqBy(arr: Array, fn: (item: T) => string) {
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index d5a0638ad34..4b36df5acad 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -7,6 +7,7 @@ import { Transitioner } from './Transitioner'
import { matchContext } from './matchContext'
import { Match } from './Match'
import { SafeFragment } from './SafeFragment'
+import type { LinkWithoutEvents, Meta } from 'unhead/types'
import type {
StructuralSharingOption,
ValidateSelected,
@@ -31,8 +32,19 @@ import type {
declare module '@tanstack/router-core' {
export interface RouteMatchExtensions {
- meta?: Array
- links?: Array
+ meta?: Array<
+ | (React.JSX.IntrinsicElements['meta'] &
+ Omit & {
+ charSet?: Meta['charset']
+ httpEquiv?: Meta['http-equiv']
+ })
+ | undefined
+ >
+ links?: Array<
+ | (React.JSX.IntrinsicElements['link'] &
+ Omit)
+ | undefined
+ >
scripts?: Array
headScripts?: Array
}
diff --git a/packages/router-core/package.json b/packages/router-core/package.json
index 3daafd8a957..98adddae85c 100644
--- a/packages/router-core/package.json
+++ b/packages/router-core/package.json
@@ -62,5 +62,8 @@
"@tanstack/history": "workspace:*",
"@tanstack/store": "^0.7.0",
"tiny-invariant": "^1.3.3"
+ },
+ "devDependencies": {
+ "unhead": "^2.0.8"
}
}
diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts
index e789e00af47..0b2edfbc764 100644
--- a/packages/router-core/src/manifest.ts
+++ b/packages/router-core/src/manifest.ts
@@ -1,3 +1,5 @@
+import type { LinkWithoutEvents, Meta } from 'unhead/types'
+
export type Manifest = {
routes: Record<
string,
@@ -16,8 +18,19 @@ export type RouterManagedTag =
children: string
}
| {
- tag: 'meta' | 'link'
- attrs?: Record
+ tag: 'meta'
+ attrs?:
+ | (Record &
+ Omit & {
+ charSet?: Meta['charset']
+ httpEquiv?: Meta['http-equiv']
+ })
+ | undefined
+ children?: never
+ }
+ | {
+ tag: 'link'
+ attrs?: Record & Omit
children?: never
}
| {
diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json
index c0f8e510bf8..3b424bda4a8 100644
--- a/packages/solid-router/package.json
+++ b/packages/solid-router/package.json
@@ -87,6 +87,7 @@
"combinate": "^1.1.11",
"eslint-plugin-solid": "^0.14.5",
"solid-js": "^1.9.5",
+ "unhead": "^2.0.8",
"vite-plugin-solid": "^2.11.2",
"zod": "^3.23.8"
},
diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx
index 512903273af..cbb557fb63d 100644
--- a/packages/solid-router/src/HeadContent.tsx
+++ b/packages/solid-router/src/HeadContent.tsx
@@ -5,6 +5,33 @@ import { useRouter } from './useRouter'
import { useRouterState } from './useRouterState'
import type { RouterManagedTag } from '@tanstack/router-core'
+const isTruthy = (val?: string | boolean) => val === '' || val === true
+const WEIGHT_MAP = {
+ meta: {
+ 'content-security-policy': -30,
+ charset: -20,
+ viewport: -15,
+ },
+ link: {
+ preconnect: 20,
+ stylesheet: 60,
+ preload: 70,
+ modulepreload: 70,
+ prefetch: 90,
+ 'dns-prefetch': 90,
+ prerender: 90,
+ },
+ script: {
+ async: 30,
+ defer: 80,
+ sync: 50,
+ },
+ style: {
+ imported: 40,
+ sync: 60,
+ },
+} as const
+
export const useTags = () => {
const router = useRouter()
@@ -132,15 +159,70 @@ export const useTags = () => {
*/
export function HeadContent() {
const tags = useTags()
+
return (
- {tags().map((tag) => (
-
- ))}
+ {tags()
+ .map(weightTags)
+ .sort((a, b) => a.weight - b.weight)
+ .map((tag) => (
+
+ ))}
)
}
+function weightTags(tag: RouterManagedTag) {
+ let weight = 100
+
+ if (tag.tag === 'title') {
+ weight = 10
+ } else if (tag.tag === 'meta') {
+ const metaType =
+ tag.attrs?.httpEquiv === 'content-security-policy'
+ ? 'content-security-policy'
+ : tag.attrs?.charSet
+ ? 'charset'
+ : tag.attrs?.name === 'viewport'
+ ? 'viewport'
+ : null
+
+ if (metaType) {
+ weight = WEIGHT_MAP.meta[metaType]
+ }
+ } else if (tag.tag === 'link' && tag.attrs?.rel) {
+ weight =
+ tag.attrs.rel in WEIGHT_MAP.link
+ ? WEIGHT_MAP.link[tag.attrs.rel as keyof typeof WEIGHT_MAP.link]
+ : weight
+ } else if (tag.tag === 'script') {
+ if (isTruthy(tag.attrs?.async)) {
+ weight = WEIGHT_MAP.script.async
+ } else if (
+ tag.attrs?.src &&
+ !isTruthy(tag.attrs.defer) &&
+ !isTruthy(tag.attrs.async) &&
+ tag.attrs.type !== 'module' &&
+ !tag.attrs.type?.endsWith('json')
+ ) {
+ weight = WEIGHT_MAP.script.sync
+ } else if (
+ isTruthy(tag.attrs?.defer) &&
+ tag.attrs.src &&
+ !isTruthy(tag.attrs.async)
+ ) {
+ weight = WEIGHT_MAP.script.defer
+ }
+ } else if (tag.tag === 'style') {
+ weight = tag.children ? WEIGHT_MAP.style.imported : WEIGHT_MAP.style.sync
+ }
+
+ return {
+ ...tag,
+ weight,
+ }
+}
+
function uniqBy(arr: Array, fn: (item: T) => string) {
const seen = new Set()
return arr.filter((item) => {
diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx
index c269f74d68b..b87a8ecfc40 100644
--- a/packages/solid-router/src/Matches.tsx
+++ b/packages/solid-router/src/Matches.tsx
@@ -7,6 +7,7 @@ import { Transitioner } from './Transitioner'
import { matchContext } from './matchContext'
import { Match } from './Match'
import { SafeFragment } from './SafeFragment'
+import type { LinkWithoutEvents, Meta } from 'unhead/types'
import type {
AnyRouter,
DeepPartial,
@@ -26,8 +27,19 @@ import type {
declare module '@tanstack/router-core' {
export interface RouteMatchExtensions {
- meta?: Array
- links?: Array
+ meta?: Array<
+ | (Solid.JSX.IntrinsicElements['meta'] &
+ Omit & {
+ charSet?: Meta['charset']
+ httpEquiv?: Meta['http-equiv']
+ })
+ | undefined
+ >
+ links?: Array<
+ | (Solid.JSX.IntrinsicElements['link'] &
+ Omit)
+ | undefined
+ >
scripts?: Array
headScripts?: Array
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d8aa651f17..d32bf9902f8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5795,6 +5795,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.0.0(react@19.0.0)
+ unhead:
+ specifier: ^2.0.8
+ version: 2.0.8
zod:
specifier: ^3.24.2
version: 3.24.2
@@ -6120,6 +6123,10 @@ importers:
tiny-invariant:
specifier: ^1.3.3
version: 1.3.3
+ devDependencies:
+ unhead:
+ specifier: ^2.0.8
+ version: 2.0.8
packages/router-devtools:
dependencies:
@@ -6389,6 +6396,9 @@ importers:
solid-js:
specifier: ^1.9.5
version: 1.9.5
+ unhead:
+ specifier: ^2.0.8
+ version: 2.0.8
vite-plugin-solid:
specifier: ^2.11.2
version: 2.11.6(@testing-library/jest-dom@6.6.3)(solid-js@1.9.5)(vite@6.1.4(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))
@@ -11031,6 +11041,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.14.1:
+ resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -13503,6 +13518,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
@@ -14845,6 +14861,9 @@ packages:
ufo@1.5.4:
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
+ ufo@1.6.1:
+ resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
+
unc-path-regex@0.1.2:
resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==}
engines: {node: '>=0.10.0'}
@@ -14864,6 +14883,9 @@ packages:
unenv@1.10.0:
resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==}
+ unhead@2.0.8:
+ resolution: {integrity: sha512-63WR+y08RZE7ChiFdgNY64haAkhCtUS5/HM7xo4Q83NA63txWbEh2WGmrKbArdQmSct+XlqbFN8ZL1yWpQEHEA==}
+
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
@@ -15512,7 +15534,7 @@ snapshots:
'@babel/types': 7.26.8
'@types/gensync': 1.0.4
convert-source-map: 2.0.0
- debug: 4.4.0(supports-color@9.4.0)
+ debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -15707,7 +15729,7 @@ snapshots:
'@babel/parser': 7.26.8
'@babel/template': 7.26.8
'@babel/types': 7.26.8
- debug: 4.4.0(supports-color@9.4.0)
+ debug: 4.4.0
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -16391,7 +16413,7 @@ snapshots:
'@eslint/config-array@0.19.2':
dependencies:
'@eslint/object-schema': 2.1.6
- debug: 4.4.0(supports-color@9.4.0)
+ debug: 4.4.0
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -16405,7 +16427,7 @@ snapshots:
'@eslint/eslintrc@3.3.0':
dependencies:
ajv: 6.12.6
- debug: 4.4.0(supports-color@9.4.0)
+ debug: 4.4.0
espree: 10.3.0
globals: 14.0.0
ignore: 5.3.2
@@ -19346,7 +19368,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.23.0
'@typescript-eslint/visitor-keys': 8.23.0
- debug: 4.4.0(supports-color@9.4.0)
+ debug: 4.4.0
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -19712,8 +19734,14 @@ snapshots:
dependencies:
acorn: 8.14.0
+ acorn-jsx@5.3.2(acorn@8.14.1):
+ dependencies:
+ acorn: 8.14.1
+
acorn@8.14.0: {}
+ acorn@8.14.1: {}
+
agent-base@6.0.2:
dependencies:
debug: 4.4.0(supports-color@9.4.0)
@@ -20390,6 +20418,10 @@ snapshots:
dependencies:
ms: 2.1.3
+ debug@4.4.0:
+ dependencies:
+ ms: 2.1.3
+
debug@4.4.0(supports-color@9.4.0):
dependencies:
ms: 2.1.3
@@ -20974,7 +21006,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
- debug: 4.4.0(supports-color@9.4.0)
+ debug: 4.4.0
escape-string-regexp: 4.0.0
eslint-scope: 8.3.0
eslint-visitor-keys: 4.2.0
@@ -21000,8 +21032,8 @@ snapshots:
espree@10.3.0:
dependencies:
- acorn: 8.14.0
- acorn-jsx: 5.3.2(acorn@8.14.0)
+ acorn: 8.14.1
+ acorn-jsx: 5.3.2(acorn@8.14.1)
eslint-visitor-keys: 4.2.0
espree@9.6.1:
@@ -22027,7 +22059,7 @@ snapshots:
node-forge: 1.3.1
pathe: 1.1.2
std-env: 3.8.0
- ufo: 1.5.4
+ ufo: 1.6.1
untun: 0.1.3
uqr: 0.1.2
@@ -22244,10 +22276,10 @@ snapshots:
mlly@1.7.4:
dependencies:
- acorn: 8.14.0
+ acorn: 8.14.1
pathe: 2.0.3
pkg-types: 1.3.1
- ufo: 1.5.4
+ ufo: 1.6.1
motion-dom@11.18.1:
dependencies:
@@ -22527,7 +22559,7 @@ snapshots:
pathe: 2.0.3
pkg-types: 1.3.1
tinyexec: 0.3.2
- ufo: 1.5.4
+ ufo: 1.6.1
object-assign@4.1.1: {}
@@ -23671,7 +23703,7 @@ snapshots:
terser@5.37.0:
dependencies:
'@jridgewell/source-map': 0.3.6
- acorn: 8.14.0
+ acorn: 8.14.1
commander: 2.20.3
source-map-support: 0.5.21
@@ -23852,6 +23884,8 @@ snapshots:
ufo@1.5.4: {}
+ ufo@1.6.1: {}
+
unc-path-regex@0.1.2: {}
uncrypto@0.1.3: {}
@@ -23875,6 +23909,10 @@ snapshots:
node-fetch-native: 1.6.6
pathe: 1.1.2
+ unhead@2.0.8:
+ dependencies:
+ hookable: 5.5.3
+
unicode-emoji-modifier-base@1.0.0: {}
unicorn-magic@0.1.0: {}
@@ -23915,7 +23953,7 @@ snapshots:
unplugin@1.16.1:
dependencies:
- acorn: 8.14.0
+ acorn: 8.14.1
webpack-virtual-modules: 0.6.2
unplugin@2.1.2: