diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 1964130edf0..1c47e42a427 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -50,10 +50,12 @@
".": {
"import": {
"types": "./dist/esm/index.d.ts",
+ "development": "./dist/esm/index.dev.js",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
+ "development": "./dist/cjs/index.dev.cjs",
"default": "./dist/cjs/index.cjs"
}
},
diff --git a/packages/react-router/src/HeadContent.dev.tsx b/packages/react-router/src/HeadContent.dev.tsx
new file mode 100644
index 00000000000..3dfaa6613e0
--- /dev/null
+++ b/packages/react-router/src/HeadContent.dev.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Asset } from './Asset'
+import { useRouter } from './useRouter'
+import { useHydrated } from './ClientOnly'
+import { useTags } from './headContentUtils'
+
+const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles'
+
+/**
+ * Render route-managed head tags (title, meta, links, styles, head scripts).
+ * Place inside the document head of your app shell.
+ *
+ * Development version: filters out dev styles link after hydration and
+ * includes a fallback cleanup effect for hydration mismatch cases.
+ *
+ * @link https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management
+ */
+export function HeadContent() {
+ const tags = useTags()
+ const router = useRouter()
+ const nonce = router.options.ssr?.nonce
+ const hydrated = useHydrated()
+
+ // Fallback cleanup for hydration mismatch cases
+ // Runs when hydration completes to remove any orphaned dev styles links from DOM
+ React.useEffect(() => {
+ if (hydrated) {
+ document
+ .querySelectorAll(`link[${DEV_STYLES_ATTR}]`)
+ .forEach((el) => el.remove())
+ }
+ }, [hydrated])
+
+ // Filter out dev styles after hydration
+ const filteredTags = hydrated
+ ? tags.filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR])
+ : tags
+
+ return (
+ <>
+ {filteredTags.map((tag) => (
+
+ ))}
+ >
+ )
+}
diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx
index 026136c60d7..e917fd95964 100644
--- a/packages/react-router/src/HeadContent.tsx
+++ b/packages/react-router/src/HeadContent.tsx
@@ -1,238 +1,7 @@
import * as React from 'react'
-import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core'
import { Asset } from './Asset'
import { useRouter } from './useRouter'
-import { useRouterState } from './useRouterState'
-import type { RouterManagedTag } from '@tanstack/router-core'
-
-/**
- * Build the list of head/link/meta/script tags to render for active matches.
- * Used internally by `HeadContent`.
- */
-export const useTags = () => {
- const router = useRouter()
- const nonce = router.options.ssr?.nonce
- const routeMeta = useRouterState({
- select: (state) => {
- return state.matches.map((match) => match.meta!).filter(Boolean)
- },
- })
-
- const meta: Array = React.useMemo(() => {
- const resultMeta: Array = []
- const metaByAttribute: Record = {}
- let title: RouterManagedTag | undefined
- for (let i = routeMeta.length - 1; i >= 0; i--) {
- const metas = routeMeta[i]!
- for (let j = metas.length - 1; j >= 0; j--) {
- const m = metas[j]
- if (!m) continue
-
- if (m.title) {
- if (!title) {
- title = {
- tag: 'title',
- children: m.title,
- }
- }
- } else if ('script:ld+json' in m) {
- // Handle JSON-LD structured data
- // Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML
- try {
- const json = JSON.stringify(m['script:ld+json'])
- resultMeta.push({
- tag: 'script',
- attrs: {
- type: 'application/ld+json',
- },
- children: escapeHtml(json),
- })
- } catch {
- // Skip invalid JSON-LD objects
- }
- } else {
- const attribute = m.name ?? m.property
- if (attribute) {
- if (metaByAttribute[attribute]) {
- continue
- } else {
- metaByAttribute[attribute] = true
- }
- }
-
- resultMeta.push({
- tag: 'meta',
- attrs: {
- ...m,
- nonce,
- },
- })
- }
- }
- }
-
- if (title) {
- resultMeta.push(title)
- }
-
- if (nonce) {
- resultMeta.push({
- tag: 'meta',
- attrs: {
- property: 'csp-nonce',
- content: nonce,
- },
- })
- }
- resultMeta.reverse()
-
- return resultMeta
- }, [routeMeta, nonce])
-
- const links = useRouterState({
- select: (state) => {
- const constructed = state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
-
- const manifest = router.ssr?.manifest
-
- // These are the assets extracted from the ViteManifest
- // using the `startManifestPlugin`
- const assets = state.matches
- .map((match) => manifest?.routes[match.routeId]?.assets ?? [])
- .filter(Boolean)
- .flat(1)
- .filter((asset) => asset.tag === 'link')
- .map(
- (asset) =>
- ({
- tag: 'link',
- attrs: {
- ...asset.attrs,
- suppressHydrationWarning: true,
- nonce,
- },
- }) satisfies RouterManagedTag,
- )
-
- return [...constructed, ...assets]
- },
- structuralSharing: true as any,
- })
-
- const preloadLinks = useRouterState({
- select: (state) => {
- const preloadLinks: Array = []
-
- state.matches
- .map((match) => router.looseRoutesById[match.routeId]!)
- .forEach((route) =>
- router.ssr?.manifest?.routes[route.id]?.preloads
- ?.filter(Boolean)
- .forEach((preload) => {
- preloadLinks.push({
- tag: 'link',
- attrs: {
- rel: 'modulepreload',
- href: preload,
- nonce,
- },
- })
- }),
- )
-
- return preloadLinks
- },
- structuralSharing: true as any,
- })
-
- const styles = useRouterState({
- select: (state) =>
- (
- state.matches
- .map((match) => match.styles!)
- .flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...attrs }) => ({
- tag: 'style',
- attrs,
- children,
- nonce,
- })),
- structuralSharing: true as any,
- })
-
- const headScripts: Array = useRouterState({
- select: (state) =>
- (
- state.matches
- .map((match) => match.headScripts!)
- .flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...script }) => ({
- tag: 'script',
- attrs: {
- ...script,
- nonce,
- },
- children,
- })),
- structuralSharing: true as any,
- })
-
- return uniqBy(
- [
- ...meta,
- ...preloadLinks,
- ...links,
- ...styles,
- ...headScripts,
- ] as Array,
- (d) => {
- return JSON.stringify(d)
- },
- )
-}
-
-/**
- * Renders a stylesheet link for dev mode CSS collection.
- * On the server, renders the full link with route-scoped CSS URL.
- * On the client, renders the same link to avoid hydration mismatch,
- * then removes it after hydration since Vite's HMR handles CSS updates.
- */
-function DevStylesLink() {
- const router = useRouter()
- const routeIds = useRouterState({
- select: (state) => state.matches.map((match) => match.routeId),
- })
-
- React.useEffect(() => {
- // After hydration, remove the SSR-rendered dev styles link
- document
- .querySelectorAll('[data-tanstack-start-dev-styles]')
- .forEach((el) => el.remove())
- }, [])
-
- const href = buildDevStylesUrl(router.basepath, routeIds)
-
- return (
-
- )
-}
+import { useTags } from './headContentUtils'
/**
* Render route-managed head tags (title, meta, links, styles, head scripts).
@@ -245,22 +14,9 @@ export function HeadContent() {
const nonce = router.options.ssr?.nonce
return (
<>
- {process.env.NODE_ENV !== 'production' && }
{tags.map((tag) => (
))}
>
)
}
-
-function uniqBy(arr: Array, fn: (item: T) => string) {
- const seen = new Set()
- return arr.filter((item) => {
- const key = fn(item)
- if (seen.has(key)) {
- return false
- }
- seen.add(key)
- return true
- })
-}
diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx
new file mode 100644
index 00000000000..1345eebb22d
--- /dev/null
+++ b/packages/react-router/src/headContentUtils.tsx
@@ -0,0 +1,217 @@
+import * as React from 'react'
+import { escapeHtml } from '@tanstack/router-core'
+import { useRouter } from './useRouter'
+import { useRouterState } from './useRouterState'
+import type { RouterManagedTag } from '@tanstack/router-core'
+
+/**
+ * Build the list of head/link/meta/script tags to render for active matches.
+ * Used internally by `HeadContent`.
+ */
+export const useTags = () => {
+ const router = useRouter()
+ const nonce = router.options.ssr?.nonce
+ const routeMeta = useRouterState({
+ select: (state) => {
+ return state.matches.map((match) => match.meta!).filter(Boolean)
+ },
+ })
+
+ const meta: Array = React.useMemo(() => {
+ const resultMeta: Array = []
+ const metaByAttribute: Record = {}
+ let title: RouterManagedTag | undefined
+ for (let i = routeMeta.length - 1; i >= 0; i--) {
+ const metas = routeMeta[i]!
+ for (let j = metas.length - 1; j >= 0; j--) {
+ const m = metas[j]
+ if (!m) continue
+
+ if (m.title) {
+ if (!title) {
+ title = {
+ tag: 'title',
+ children: m.title,
+ }
+ }
+ } else if ('script:ld+json' in m) {
+ // Handle JSON-LD structured data
+ // Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML
+ try {
+ const json = JSON.stringify(m['script:ld+json'])
+ resultMeta.push({
+ tag: 'script',
+ attrs: {
+ type: 'application/ld+json',
+ },
+ children: escapeHtml(json),
+ })
+ } catch {
+ // Skip invalid JSON-LD objects
+ }
+ } else {
+ const attribute = m.name ?? m.property
+ if (attribute) {
+ if (metaByAttribute[attribute]) {
+ continue
+ } else {
+ metaByAttribute[attribute] = true
+ }
+ }
+
+ resultMeta.push({
+ tag: 'meta',
+ attrs: {
+ ...m,
+ nonce,
+ },
+ })
+ }
+ }
+ }
+
+ if (title) {
+ resultMeta.push(title)
+ }
+
+ if (nonce) {
+ resultMeta.push({
+ tag: 'meta',
+ attrs: {
+ property: 'csp-nonce',
+ content: nonce,
+ },
+ })
+ }
+ resultMeta.reverse()
+
+ return resultMeta
+ }, [routeMeta, nonce])
+
+ const links = useRouterState({
+ select: (state) => {
+ const constructed = state.matches
+ .map((match) => match.links!)
+ .filter(Boolean)
+ .flat(1)
+ .map((link) => ({
+ tag: 'link',
+ attrs: {
+ ...link,
+ nonce,
+ },
+ })) satisfies Array
+
+ const manifest = router.ssr?.manifest
+
+ // These are the assets extracted from the ViteManifest
+ // using the `startManifestPlugin`
+ const assets = state.matches
+ .map((match) => manifest?.routes[match.routeId]?.assets ?? [])
+ .filter(Boolean)
+ .flat(1)
+ .filter((asset) => asset.tag === 'link')
+ .map(
+ (asset) =>
+ ({
+ tag: 'link',
+ attrs: {
+ ...asset.attrs,
+ suppressHydrationWarning: true,
+ nonce,
+ },
+ }) satisfies RouterManagedTag,
+ )
+
+ return [...constructed, ...assets]
+ },
+ structuralSharing: true as any,
+ })
+
+ const preloadLinks = useRouterState({
+ select: (state) => {
+ const preloadLinks: Array = []
+
+ state.matches
+ .map((match) => router.looseRoutesById[match.routeId]!)
+ .forEach((route) =>
+ router.ssr?.manifest?.routes[route.id]?.preloads
+ ?.filter(Boolean)
+ .forEach((preload) => {
+ preloadLinks.push({
+ tag: 'link',
+ attrs: {
+ rel: 'modulepreload',
+ href: preload,
+ nonce,
+ },
+ })
+ }),
+ )
+
+ return preloadLinks
+ },
+ structuralSharing: true as any,
+ })
+
+ const styles = useRouterState({
+ select: (state) =>
+ (
+ state.matches
+ .map((match) => match.styles!)
+ .flat(1)
+ .filter(Boolean) as Array
+ ).map(({ children, ...attrs }) => ({
+ tag: 'style',
+ attrs: {
+ ...attrs,
+ nonce,
+ },
+ children,
+ })),
+ structuralSharing: true as any,
+ })
+
+ const headScripts: Array = useRouterState({
+ select: (state) =>
+ (
+ state.matches
+ .map((match) => match.headScripts!)
+ .flat(1)
+ .filter(Boolean) as Array
+ ).map(({ children, ...script }) => ({
+ tag: 'script',
+ attrs: {
+ ...script,
+ nonce,
+ },
+ children,
+ })),
+ structuralSharing: true as any,
+ })
+
+ return uniqBy(
+ [
+ ...meta,
+ ...preloadLinks,
+ ...links,
+ ...styles,
+ ...headScripts,
+ ] as Array,
+ (d) => {
+ return JSON.stringify(d)
+ },
+ )
+}
+
+export function uniqBy(arr: Array, fn: (item: T) => string) {
+ const seen = new Set()
+ return arr.filter((item) => {
+ const key = fn(item)
+ if (seen.has(key)) {
+ return false
+ }
+ seen.add(key)
+ return true
+ })
+}
diff --git a/packages/react-router/src/index.dev.tsx b/packages/react-router/src/index.dev.tsx
new file mode 100644
index 00000000000..e7c962f3d69
--- /dev/null
+++ b/packages/react-router/src/index.dev.tsx
@@ -0,0 +1,6 @@
+// Development entry point - re-exports everything from index.tsx
+// but overrides HeadContent with the dev version that handles
+// dev styles cleanup after hydration
+
+export * from './index'
+export { HeadContent } from './HeadContent.dev'
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index 9779e391b38..261bd099953 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -343,6 +343,7 @@ export type {
export { ScriptOnce } from './ScriptOnce'
export { Asset } from './Asset'
export { HeadContent } from './HeadContent'
+export { useTags } from './headContentUtils'
export { Scripts } from './Scripts'
export type * from './ssr/serializer'
export { composeRewrites } from '@tanstack/router-core'
diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx
index ddd11cf1a79..315b3e528e5 100644
--- a/packages/react-router/tests/Scripts.test.tsx
+++ b/packages/react-router/tests/Scripts.test.tsx
@@ -217,7 +217,7 @@ describe('ssr HeadContent', () => {
,
)
expect(html).toEqual(
- `Index`,
+ `Index`,
)
})
})
diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts
index 5e60c3cd1c6..836afe7e03a 100644
--- a/packages/react-router/vite.config.ts
+++ b/packages/react-router/vite.config.ts
@@ -19,7 +19,12 @@ const config = defineConfig({
export default mergeConfig(
config,
tanstackViteConfig({
- entry: ['./src/index.tsx', './src/ssr/client.ts', './src/ssr/server.ts'],
+ entry: [
+ './src/index.tsx',
+ './src/index.dev.tsx',
+ './src/ssr/client.ts',
+ './src/ssr/server.ts',
+ ],
srcDir: './src',
}),
)
diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json
index 00d7b52dbc3..4385840c330 100644
--- a/packages/solid-router/package.json
+++ b/packages/solid-router/package.json
@@ -48,14 +48,17 @@
".": {
"solid": {
"types": "./dist/source/index.d.ts",
+ "development": "./dist/source/index.dev.jsx",
"default": "./dist/source/index.jsx"
},
"import": {
"types": "./dist/esm/index.d.ts",
+ "development": "./dist/esm/index.dev.js",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
+ "development": "./dist/cjs/index.dev.cjs",
"default": "./dist/cjs/index.cjs"
}
},
diff --git a/packages/solid-router/src/HeadContent.dev.tsx b/packages/solid-router/src/HeadContent.dev.tsx
new file mode 100644
index 00000000000..c29ad96eabe
--- /dev/null
+++ b/packages/solid-router/src/HeadContent.dev.tsx
@@ -0,0 +1,45 @@
+import { MetaProvider } from '@solidjs/meta'
+import { For, createEffect, createMemo } from 'solid-js'
+import { Asset } from './Asset'
+import { useHydrated } from './ClientOnly'
+import { useTags } from './headContentUtils'
+
+const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles'
+
+/**
+ * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route.
+ * When using full document hydration (hydrating from ``), this component should be rendered in the ``
+ * to ensure it's part of the reactive tree and updates correctly during client-side navigation.
+ * The component uses portals internally to render content into the `` element.
+ *
+ * Development version: filters out dev styles link after hydration and
+ * includes a fallback cleanup effect for hydration mismatch cases.
+ */
+export function HeadContent() {
+ const tags = useTags()
+ const hydrated = useHydrated()
+
+ // Fallback cleanup for hydration mismatch cases
+ // Runs when hydration completes to remove any orphaned dev styles links from DOM
+ createEffect(() => {
+ if (hydrated()) {
+ document
+ .querySelectorAll(`link[${DEV_STYLES_ATTR}]`)
+ .forEach((el) => el.remove())
+ }
+ })
+
+ // Filter out dev styles after hydration
+ const filteredTags = createMemo(() => {
+ if (hydrated()) {
+ return tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR])
+ }
+ return tags()
+ })
+
+ return (
+
+ {(tag) => }
+
+ )
+}
diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx
index e6cf3e44f86..8a02f146a70 100644
--- a/packages/solid-router/src/HeadContent.tsx
+++ b/packages/solid-router/src/HeadContent.tsx
@@ -1,225 +1,7 @@
-import * as Solid from 'solid-js'
import { MetaProvider } from '@solidjs/meta'
-import { For, Show, onMount } from 'solid-js'
-import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core'
+import { For } from 'solid-js'
import { Asset } from './Asset'
-import { useRouter } from './useRouter'
-import { useRouterState } from './useRouterState'
-import type { RouterManagedTag } from '@tanstack/router-core'
-
-export const useTags = () => {
- const router = useRouter()
- const nonce = router.options.ssr?.nonce
- const routeMeta = useRouterState({
- select: (state) => {
- return state.matches.map((match) => match.meta!).filter(Boolean)
- },
- })
-
- const meta: Solid.Accessor> = Solid.createMemo(() => {
- const resultMeta: Array = []
- const metaByAttribute: Record = {}
- let title: RouterManagedTag | undefined
- const routeMetasArray = routeMeta()
- for (let i = routeMetasArray.length - 1; i >= 0; i--) {
- const metas = routeMetasArray[i]!
- for (let j = metas.length - 1; j >= 0; j--) {
- const m = metas[j]
- if (!m) continue
-
- if (m.title) {
- if (!title) {
- title = {
- tag: 'title',
- children: m.title,
- }
- }
- } else if ('script:ld+json' in m) {
- // Handle JSON-LD structured data
- // Content is HTML-escaped to prevent XSS when injected via innerHTML
- try {
- const json = JSON.stringify(m['script:ld+json'])
- resultMeta.push({
- tag: 'script',
- attrs: {
- type: 'application/ld+json',
- },
- children: escapeHtml(json),
- })
- } catch {
- // Skip invalid JSON-LD objects
- }
- } else {
- const attribute = m.name ?? m.property
- if (attribute) {
- if (metaByAttribute[attribute]) {
- continue
- } else {
- metaByAttribute[attribute] = true
- }
- }
-
- resultMeta.push({
- tag: 'meta',
- attrs: {
- ...m,
- nonce,
- },
- })
- }
- }
- }
-
- if (title) {
- resultMeta.push(title)
- }
-
- if (router.options.ssr?.nonce) {
- resultMeta.push({
- tag: 'meta',
- attrs: {
- property: 'csp-nonce',
- content: router.options.ssr.nonce,
- },
- })
- }
- resultMeta.reverse()
-
- return resultMeta
- })
-
- const links = useRouterState({
- select: (state) => {
- const constructed = state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
-
- const manifest = router.ssr?.manifest
-
- // These are the assets extracted from the ViteManifest
- // using the `startManifestPlugin`
- const assets = state.matches
- .map((match) => manifest?.routes[match.routeId]?.assets ?? [])
- .filter(Boolean)
- .flat(1)
- .filter((asset) => asset.tag === 'link')
- .map(
- (asset) =>
- ({
- tag: 'link',
- attrs: { ...asset.attrs, nonce },
- }) satisfies RouterManagedTag,
- )
-
- return [...constructed, ...assets]
- },
- })
-
- const preloadLinks = useRouterState({
- select: (state) => {
- const preloadLinks: Array = []
-
- state.matches
- .map((match) => router.looseRoutesById[match.routeId]!)
- .forEach((route) =>
- router.ssr?.manifest?.routes[route.id]?.preloads
- ?.filter(Boolean)
- .forEach((preload) => {
- preloadLinks.push({
- tag: 'link',
- attrs: {
- rel: 'modulepreload',
- href: preload,
- nonce,
- },
- })
- }),
- )
-
- return preloadLinks
- },
- })
-
- const styles = useRouterState({
- select: (state) =>
- (
- state.matches
- .map((match) => match.styles!)
- .flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...style }) => ({
- tag: 'style',
- attrs: {
- ...style,
- nonce,
- },
- children,
- })),
- })
-
- const headScripts = useRouterState({
- select: (state) =>
- (
- state.matches
- .map((match) => match.headScripts!)
- .flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...script }) => ({
- tag: 'script',
- attrs: {
- ...script,
- nonce,
- },
- children,
- })),
- })
-
- return () =>
- uniqBy(
- [
- ...meta(),
- ...preloadLinks(),
- ...links(),
- ...styles(),
- ...headScripts(),
- ] as Array,
- (d) => {
- return JSON.stringify(d)
- },
- )
-}
-
-/**
- * Renders a stylesheet link for dev mode CSS collection.
- * On the server, renders the full link with route-scoped CSS URL.
- * On the client, renders the same link to avoid hydration mismatch,
- * then removes it after hydration since Vite's HMR handles CSS updates.
- */
-function DevStylesLink() {
- const router = useRouter()
- const routeIds = useRouterState({
- select: (state) => state.matches.map((match) => match.routeId),
- })
-
- onMount(() => {
- // After hydration, remove the SSR-rendered dev styles link
- document
- .querySelectorAll('[data-tanstack-start-dev-styles]')
- .forEach((el) => el.remove())
- })
-
- const href = () => buildDevStylesUrl(router.basepath, routeIds())
-
- return
-}
+import { useTags } from './headContentUtils'
/**
* @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route.
@@ -232,22 +14,7 @@ export function HeadContent() {
return (
-
-
-
{(tag) => }
)
}
-
-function uniqBy(arr: Array, fn: (item: T) => string) {
- const seen = new Set()
- return arr.filter((item) => {
- const key = fn(item)
- if (seen.has(key)) {
- return false
- }
- seen.add(key)
- return true
- })
-}
diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx
new file mode 100644
index 00000000000..1aaf927afae
--- /dev/null
+++ b/packages/solid-router/src/headContentUtils.tsx
@@ -0,0 +1,209 @@
+import * as Solid from 'solid-js'
+import { escapeHtml } from '@tanstack/router-core'
+import { useRouter } from './useRouter'
+import { useRouterState } from './useRouterState'
+import type { RouterManagedTag } from '@tanstack/router-core'
+
+/**
+ * Build the list of head/link/meta/script tags to render for active matches.
+ * Used internally by `HeadContent`.
+ */
+export const useTags = () => {
+ const router = useRouter()
+ const nonce = router.options.ssr?.nonce
+ const routeMeta = useRouterState({
+ select: (state) => {
+ return state.matches.map((match) => match.meta!).filter(Boolean)
+ },
+ })
+
+ const meta: Solid.Accessor> = Solid.createMemo(() => {
+ const resultMeta: Array = []
+ const metaByAttribute: Record = {}
+ let title: RouterManagedTag | undefined
+ const routeMetasArray = routeMeta()
+ for (let i = routeMetasArray.length - 1; i >= 0; i--) {
+ const metas = routeMetasArray[i]!
+ for (let j = metas.length - 1; j >= 0; j--) {
+ const m = metas[j]
+ if (!m) continue
+
+ if (m.title) {
+ if (!title) {
+ title = {
+ tag: 'title',
+ children: m.title,
+ }
+ }
+ } else if ('script:ld+json' in m) {
+ // Handle JSON-LD structured data
+ // Content is HTML-escaped to prevent XSS when injected via innerHTML
+ try {
+ const json = JSON.stringify(m['script:ld+json'])
+ resultMeta.push({
+ tag: 'script',
+ attrs: {
+ type: 'application/ld+json',
+ },
+ children: escapeHtml(json),
+ })
+ } catch {
+ // Skip invalid JSON-LD objects
+ }
+ } else {
+ const attribute = m.name ?? m.property
+ if (attribute) {
+ if (metaByAttribute[attribute]) {
+ continue
+ } else {
+ metaByAttribute[attribute] = true
+ }
+ }
+
+ resultMeta.push({
+ tag: 'meta',
+ attrs: {
+ ...m,
+ nonce,
+ },
+ })
+ }
+ }
+ }
+
+ if (title) {
+ resultMeta.push(title)
+ }
+
+ if (router.options.ssr?.nonce) {
+ resultMeta.push({
+ tag: 'meta',
+ attrs: {
+ property: 'csp-nonce',
+ content: router.options.ssr.nonce,
+ },
+ })
+ }
+ resultMeta.reverse()
+
+ return resultMeta
+ })
+
+ const links = useRouterState({
+ select: (state) => {
+ const constructed = state.matches
+ .map((match) => match.links!)
+ .filter(Boolean)
+ .flat(1)
+ .map((link) => ({
+ tag: 'link',
+ attrs: {
+ ...link,
+ nonce,
+ },
+ })) satisfies Array
+
+ const manifest = router.ssr?.manifest
+
+ const assets = state.matches
+ .map((match) => manifest?.routes[match.routeId]?.assets ?? [])
+ .filter(Boolean)
+ .flat(1)
+ .filter((asset) => asset.tag === 'link')
+ .map(
+ (asset) =>
+ ({
+ tag: 'link',
+ attrs: { ...asset.attrs, nonce },
+ }) satisfies RouterManagedTag,
+ )
+
+ return [...constructed, ...assets]
+ },
+ })
+
+ const preloadLinks = useRouterState({
+ select: (state) => {
+ const preloadLinks: Array = []
+
+ state.matches
+ .map((match) => router.looseRoutesById[match.routeId]!)
+ .forEach((route) =>
+ router.ssr?.manifest?.routes[route.id]?.preloads
+ ?.filter(Boolean)
+ .forEach((preload) => {
+ preloadLinks.push({
+ tag: 'link',
+ attrs: {
+ rel: 'modulepreload',
+ href: preload,
+ nonce,
+ },
+ })
+ }),
+ )
+
+ return preloadLinks
+ },
+ })
+
+ const styles = useRouterState({
+ select: (state) =>
+ (
+ state.matches
+ .map((match) => match.styles!)
+ .flat(1)
+ .filter(Boolean) as Array
+ ).map(({ children, ...style }) => ({
+ tag: 'style',
+ attrs: {
+ ...style,
+ nonce,
+ },
+ children,
+ })),
+ })
+
+ const headScripts = useRouterState({
+ select: (state) =>
+ (
+ state.matches
+ .map((match) => match.headScripts!)
+ .flat(1)
+ .filter(Boolean) as Array
+ ).map(({ children, ...script }) => ({
+ tag: 'script',
+ attrs: {
+ ...script,
+ nonce,
+ },
+ children,
+ })),
+ })
+
+ return () =>
+ uniqBy(
+ [
+ ...meta(),
+ ...preloadLinks(),
+ ...links(),
+ ...styles(),
+ ...headScripts(),
+ ] as Array,
+ (d) => {
+ return JSON.stringify(d)
+ },
+ )
+}
+
+export function uniqBy(arr: Array, fn: (item: T) => string) {
+ const seen = new Set()
+ return arr.filter((item) => {
+ const key = fn(item)
+ if (seen.has(key)) {
+ return false
+ }
+ seen.add(key)
+ return true
+ })
+}
diff --git a/packages/solid-router/src/index.dev.tsx b/packages/solid-router/src/index.dev.tsx
new file mode 100644
index 00000000000..e7c962f3d69
--- /dev/null
+++ b/packages/solid-router/src/index.dev.tsx
@@ -0,0 +1,6 @@
+// Development entry point - re-exports everything from index.tsx
+// but overrides HeadContent with the dev version that handles
+// dev styles cleanup after hydration
+
+export * from './index'
+export { HeadContent } from './HeadContent.dev'
diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx
index 6d0c31f2fce..929738e87ec 100644
--- a/packages/solid-router/src/index.tsx
+++ b/packages/solid-router/src/index.tsx
@@ -346,7 +346,8 @@ export type {
export { ScriptOnce } from './ScriptOnce'
export { Asset } from './Asset'
-export { HeadContent, useTags } from './HeadContent'
+export { HeadContent } from './HeadContent'
+export { useTags } from './headContentUtils'
export { Scripts } from './Scripts'
export { composeRewrites } from '@tanstack/router-core'
export type {
diff --git a/packages/solid-router/src/ssr/RouterServer.tsx b/packages/solid-router/src/ssr/RouterServer.tsx
index 99b4e684bbb..baa1c455029 100644
--- a/packages/solid-router/src/ssr/RouterServer.tsx
+++ b/packages/solid-router/src/ssr/RouterServer.tsx
@@ -7,7 +7,7 @@ import {
} from 'solid-js/web'
import { MetaProvider } from '@solidjs/meta'
import { Asset } from '../Asset'
-import { useTags } from '../HeadContent'
+import { useTags } from '../headContentUtils'
import { RouterProvider } from '../RouterProvider'
import { Scripts } from '../Scripts'
import type { AnyRouter } from '@tanstack/router-core'
diff --git a/packages/solid-router/vite.config.ts b/packages/solid-router/vite.config.ts
index 0d90e89b746..33d5d760415 100644
--- a/packages/solid-router/vite.config.ts
+++ b/packages/solid-router/vite.config.ts
@@ -36,7 +36,12 @@ export default defineConfig((env) =>
mergeConfig(
config(env),
tanstackViteConfig({
- entry: ['./src/index.tsx', './src/ssr/client.ts', './src/ssr/server.ts'],
+ entry: [
+ './src/index.tsx',
+ './src/index.dev.tsx',
+ './src/ssr/client.ts',
+ './src/ssr/server.ts',
+ ],
srcDir: './src',
}),
),
diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
index 85a673a4fa4..c35a24f3092 100644
--- a/packages/start-server-core/src/createStartHandler.ts
+++ b/packages/start-server-core/src/createStartHandler.ts
@@ -82,7 +82,12 @@ function getEntries() {
return entriesPromise
}
-function getManifest() {
+function getManifest(matchedRoutes?: ReadonlyArray) {
+ // In dev mode, always get fresh manifest (no caching) to include route-specific dev styles
+ if (process.env.TSS_DEV_SERVER === 'true') {
+ return getStartManifest(matchedRoutes)
+ }
+ // In prod, cache the manifest
if (!manifestPromise) {
manifestPromise = getStartManifest()
}
@@ -313,7 +318,10 @@ export function createStartHandler(
}
// Router execution function
- const executeRouter = async (serverContext: TODO): Promise => {
+ const executeRouter = async (
+ serverContext: TODO,
+ matchedRoutes?: ReadonlyArray,
+ ): Promise => {
const acceptHeader = request.headers.get('Accept') || '*/*'
const acceptParts = acceptHeader.split(',')
const supportedMimeTypes = ['*/*', 'text/html']
@@ -329,7 +337,7 @@ export function createStartHandler(
)
}
- const manifest = await getManifest()
+ const manifest = await getManifest(matchedRoutes)
const routerInstance = await getRouter()
attachRouterServerSsrUtils({
@@ -477,7 +485,10 @@ async function handleServerRoutes({
getRouter: () => Promise
request: Request
url: URL
- executeRouter: (serverContext: any) => Promise
+ executeRouter: (
+ serverContext: any,
+ matchedRoutes?: ReadonlyArray,
+ ) => Promise
context: any
executedRequestMiddlewares: Set
}): Promise {
@@ -541,8 +552,10 @@ async function handleServerRoutes({
}
}
- // Final middleware: execute router
- routeMiddlewares.push((ctx: TODO) => executeRouter(ctx.context))
+ // Final middleware: execute router with matched routes for dev styles
+ routeMiddlewares.push((ctx: TODO) =>
+ executeRouter(ctx.context, matchedRoutes),
+ )
const ctx = await executeMiddleware(routeMiddlewares, {
request,
diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts
index 8e6f268f977..6fe5e5c1a29 100644
--- a/packages/start-server-core/src/router-manifest.ts
+++ b/packages/start-server-core/src/router-manifest.ts
@@ -1,13 +1,21 @@
-import { rootRouteId } from '@tanstack/router-core'
-import type { RouterManagedTag } from '@tanstack/router-core'
+import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core'
+import type { AnyRoute, RouterManagedTag } from '@tanstack/router-core'
+
+// Pre-computed constant for dev styles URL
+const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/'
/**
* @description Returns the router manifest that should be sent to the client.
* This includes only the assets and preloads for the current route and any
* special assets that are needed for the client. It does not include relationships
* between routes or any other data that is not needed for the client.
+ *
+ * @param matchedRoutes - In dev mode, the matched routes are used to build
+ * the dev styles URL for route-scoped CSS collection.
*/
-export async function getStartManifest() {
+export async function getStartManifest(
+ matchedRoutes?: ReadonlyArray,
+) {
const { tsrStartManifest } = await import('tanstack-start-manifest:v')
const startManifest = tsrStartManifest()
@@ -16,6 +24,19 @@ export async function getStartManifest() {
rootRoute.assets = rootRoute.assets || []
+ // Inject dev styles link in dev mode
+ if (process.env.TSS_DEV_SERVER === 'true' && matchedRoutes) {
+ const matchedRouteIds = matchedRoutes.map((route) => route.id)
+ rootRoute.assets.push({
+ tag: 'link',
+ attrs: {
+ rel: 'stylesheet',
+ href: buildDevStylesUrl(ROUTER_BASEPATH, matchedRouteIds),
+ 'data-tanstack-router-dev-styles': 'true',
+ },
+ })
+ }
+
let script = `import('${startManifest.clientEntry}')`
if (process.env.TSS_DEV_SERVER === 'true') {
const { injectedHeadScripts } = await import(
diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json
index b7a05120ec9..d2acf94918c 100644
--- a/packages/vue-router/package.json
+++ b/packages/vue-router/package.json
@@ -43,6 +43,7 @@
".": {
"import": {
"types": "./dist/esm/index.d.ts",
+ "development": "./dist/esm/index.dev.js",
"default": "./dist/esm/index.js"
}
},
diff --git a/packages/vue-router/src/HeadContent.dev.tsx b/packages/vue-router/src/HeadContent.dev.tsx
new file mode 100644
index 00000000000..03a73557697
--- /dev/null
+++ b/packages/vue-router/src/HeadContent.dev.tsx
@@ -0,0 +1,42 @@
+import * as Vue from 'vue'
+
+import { Asset } from './Asset'
+import { useHydrated } from './ClientOnly'
+import { useTags } from './headContentUtils'
+
+const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles'
+
+/**
+ * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route.
+ * It should be rendered in the `` of your document.
+ *
+ * This is the development version that filters out dev styles after hydration.
+ */
+export const HeadContent = Vue.defineComponent({
+ name: 'HeadContent',
+ setup() {
+ const tags = useTags()
+ const hydrated = useHydrated()
+
+ // Fallback cleanup for hydration mismatch cases
+ Vue.onMounted(() => {
+ document
+ .querySelectorAll(`link[${DEV_STYLES_ATTR}]`)
+ .forEach((el) => el.remove())
+ })
+
+ return () => {
+ // Filter out dev styles after hydration
+ const filteredTags = hydrated.value
+ ? tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR])
+ : tags()
+
+ return filteredTags.map((tag) =>
+ Vue.h(Asset, {
+ ...tag,
+ key: `tsr-meta-${JSON.stringify(tag)}`,
+ }),
+ )
+ }
+ },
+})
diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx
index a022b77b376..0d2b41736ee 100644
--- a/packages/vue-router/src/HeadContent.tsx
+++ b/packages/vue-router/src/HeadContent.tsx
@@ -1,180 +1,7 @@
import * as Vue from 'vue'
-import { buildDevStylesUrl, escapeHtml } from '@tanstack/router-core'
import { Asset } from './Asset'
-import { useRouter } from './useRouter'
-import { useRouterState } from './useRouterState'
-import type { RouterManagedTag } from '@tanstack/router-core'
-
-/**
- * Renders a stylesheet link for dev mode CSS collection.
- * On the server, renders the full link with route-scoped CSS URL.
- * On the client, renders the same link to avoid hydration mismatch,
- * then removes it after hydration since Vite's HMR handles CSS updates.
- */
-const DevStylesLink = Vue.defineComponent({
- name: 'DevStylesLink',
- setup() {
- const router = useRouter()
- const routeIds = useRouterState({
- select: (state) => state.matches.map((match) => match.routeId),
- })
-
- Vue.onMounted(() => {
- // After hydration, remove the SSR-rendered dev styles link
- document
- .querySelectorAll('[data-tanstack-start-dev-styles]')
- .forEach((el) => el.remove())
- })
-
- const href = Vue.computed(() =>
- buildDevStylesUrl(router.basepath, routeIds.value),
- )
-
- return () =>
- Vue.h('link', {
- rel: 'stylesheet',
- href: href.value,
- 'data-tanstack-start-dev-styles': true,
- })
- },
-})
-
-export const useTags = () => {
- const router = useRouter()
-
- const routeMeta = useRouterState({
- select: (state) => {
- return state.matches.map((match) => match.meta!).filter(Boolean)
- },
- })
-
- const meta: Vue.Ref> = Vue.computed(() => {
- const resultMeta: Array = []
- const metaByAttribute: Record = {}
- let title: RouterManagedTag | undefined
- ;[...routeMeta.value].reverse().forEach((metas) => {
- ;[...metas].reverse().forEach((m) => {
- if (!m) return
-
- if (m.title) {
- if (!title) {
- title = {
- tag: 'title',
- children: m.title,
- }
- }
- } else if ('script:ld+json' in m) {
- // Handle JSON-LD structured data
- // Content is HTML-escaped to prevent XSS when injected via innerHTML
- try {
- const json = JSON.stringify(m['script:ld+json'])
- resultMeta.push({
- tag: 'script',
- attrs: {
- type: 'application/ld+json',
- },
- children: escapeHtml(json),
- })
- } catch {
- // Skip invalid JSON-LD objects
- }
- } else {
- const attribute = m.name ?? m.property
- if (attribute) {
- if (metaByAttribute[attribute]) {
- return
- } else {
- metaByAttribute[attribute] = true
- }
- }
-
- resultMeta.push({
- tag: 'meta',
- attrs: {
- ...m,
- },
- })
- }
- })
- })
-
- if (title) {
- resultMeta.push(title)
- }
-
- resultMeta.reverse()
-
- return resultMeta
- })
-
- const links = useRouterState({
- select: (state) =>
- state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- },
- })) as Array,
- })
-
- const preloadMeta = useRouterState({
- select: (state) => {
- const preloadMeta: Array = []
-
- state.matches
- .map((match) => router.looseRoutesById[match.routeId]!)
- .forEach((route) =>
- router.ssr?.manifest?.routes[route.id]?.preloads
- ?.filter(Boolean)
- .forEach((preload) => {
- preloadMeta.push({
- tag: 'link',
- attrs: {
- rel: 'modulepreload',
- href: preload,
- },
- })
- }),
- )
-
- return preloadMeta
- },
- })
-
- const headScripts = useRouterState({
- select: (state) =>
- (
- state.matches
- .map((match) => match.headScripts!)
- .flat(1)
- .filter(Boolean) as Array
- ).map(({ children, ...script }) => ({
- tag: 'script',
- attrs: {
- ...script,
- },
- children,
- })),
- })
-
- return () =>
- uniqBy(
- [
- ...meta.value,
- ...preloadMeta.value,
- ...links.value,
- ...headScripts.value,
- ] as Array,
- (d) => {
- return JSON.stringify(d)
- },
- )
-}
+import { useTags } from './headContentUtils'
/**
* @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route.
@@ -186,31 +13,12 @@ export const HeadContent = Vue.defineComponent({
const tags = useTags()
return () => {
- const children = tags().map((tag) =>
+ return tags().map((tag) =>
Vue.h(Asset, {
...tag,
key: `tsr-meta-${JSON.stringify(tag)}`,
}),
)
-
- // In dev mode, prepend the DevStylesLink
- if (process.env.NODE_ENV !== 'production') {
- return [Vue.h(DevStylesLink), ...children]
- }
-
- return children
}
},
})
-
-function uniqBy(arr: Array, fn: (item: T) => string) {
- const seen = new Set()
- return arr.filter((item) => {
- const key = fn(item)
- if (seen.has(key)) {
- return false
- }
- seen.add(key)
- return true
- })
-}
diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx
new file mode 100644
index 00000000000..ae0fadbff1a
--- /dev/null
+++ b/packages/vue-router/src/headContentUtils.tsx
@@ -0,0 +1,176 @@
+import * as Vue from 'vue'
+
+import { escapeHtml } from '@tanstack/router-core'
+import { useRouter } from './useRouter'
+import { useRouterState } from './useRouterState'
+import type { RouterManagedTag } from '@tanstack/router-core'
+
+export const useTags = () => {
+ const router = useRouter()
+
+ const routeMeta = useRouterState({
+ select: (state) => {
+ return state.matches.map((match) => match.meta!).filter(Boolean)
+ },
+ })
+
+ const meta: Vue.Ref> = Vue.computed(() => {
+ const resultMeta: Array = []
+ const metaByAttribute: Record = {}
+ let title: RouterManagedTag | undefined
+ ;[...routeMeta.value].reverse().forEach((metas) => {
+ ;[...metas].reverse().forEach((m) => {
+ if (!m) return
+
+ if (m.title) {
+ if (!title) {
+ title = {
+ tag: 'title',
+ children: m.title,
+ }
+ }
+ } else if ('script:ld+json' in m) {
+ // Handle JSON-LD structured data
+ // Content is HTML-escaped to prevent XSS when injected via innerHTML
+ try {
+ const json = JSON.stringify(m['script:ld+json'])
+ resultMeta.push({
+ tag: 'script',
+ attrs: {
+ type: 'application/ld+json',
+ },
+ children: escapeHtml(json),
+ })
+ } catch {
+ // Skip invalid JSON-LD objects
+ }
+ } else {
+ const attribute = m.name ?? m.property
+ if (attribute) {
+ if (metaByAttribute[attribute]) {
+ return
+ } else {
+ metaByAttribute[attribute] = true
+ }
+ }
+
+ resultMeta.push({
+ tag: 'meta',
+ attrs: {
+ ...m,
+ },
+ })
+ }
+ })
+ })
+
+ if (title) {
+ resultMeta.push(title)
+ }
+
+ resultMeta.reverse()
+
+ return resultMeta
+ })
+
+ const links = useRouterState({
+ select: (state) =>
+ state.matches
+ .map((match) => match.links!)
+ .filter(Boolean)
+ .flat(1)
+ .map((link) => ({
+ tag: 'link',
+ attrs: {
+ ...link,
+ },
+ })) as Array,
+ })
+
+ const preloadMeta = useRouterState({
+ select: (state) => {
+ const preloadMeta: Array = []
+
+ state.matches
+ .map((match) => router.looseRoutesById[match.routeId]!)
+ .forEach((route) =>
+ router.ssr?.manifest?.routes[route.id]?.preloads
+ ?.filter(Boolean)
+ .forEach((preload) => {
+ preloadMeta.push({
+ tag: 'link',
+ attrs: {
+ rel: 'modulepreload',
+ href: preload,
+ },
+ })
+ }),
+ )
+
+ return preloadMeta
+ },
+ })
+
+ const headScripts = useRouterState({
+ select: (state) =>
+ (
+ state.matches
+ .map((match) => match.headScripts!)
+ .flat(1)
+ .filter(Boolean) as Array
+ ).map(({ children, ...script }) => ({
+ tag: 'script',
+ attrs: {
+ ...script,
+ },
+ children,
+ })),
+ })
+
+ const manifestAssets = useRouterState({
+ select: (state) => {
+ const manifest = router.ssr?.manifest
+
+ const assets = state.matches
+ .map((match) => manifest?.routes[match.routeId]?.assets ?? [])
+ .filter(Boolean)
+ .flat(1)
+ .filter((asset) => asset.tag === 'link')
+ .map(
+ (asset) =>
+ ({
+ tag: 'link',
+ attrs: { ...asset.attrs },
+ }) satisfies RouterManagedTag,
+ )
+
+ return assets
+ },
+ })
+
+ return () =>
+ uniqBy(
+ [
+ ...manifestAssets.value,
+ ...meta.value,
+ ...preloadMeta.value,
+ ...links.value,
+ ...headScripts.value,
+ ] as Array,
+ (d) => {
+ return JSON.stringify(d)
+ },
+ )
+}
+
+export function uniqBy(arr: Array, fn: (item: T) => string) {
+ const seen = new Set()
+ return arr.filter((item) => {
+ const key = fn(item)
+ if (seen.has(key)) {
+ return false
+ }
+ seen.add(key)
+ return true
+ })
+}
diff --git a/packages/vue-router/src/index.dev.tsx b/packages/vue-router/src/index.dev.tsx
new file mode 100644
index 00000000000..e7c962f3d69
--- /dev/null
+++ b/packages/vue-router/src/index.dev.tsx
@@ -0,0 +1,6 @@
+// Development entry point - re-exports everything from index.tsx
+// but overrides HeadContent with the dev version that handles
+// dev styles cleanup after hydration
+
+export * from './index'
+export { HeadContent } from './HeadContent.dev'
diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx
index 3e6b1615161..3817956431b 100644
--- a/packages/vue-router/src/index.tsx
+++ b/packages/vue-router/src/index.tsx
@@ -339,6 +339,7 @@ export type {
export { ScriptOnce } from './ScriptOnce'
export { Asset } from './Asset'
export { HeadContent } from './HeadContent'
+export { useTags } from './headContentUtils'
export { Scripts } from './Scripts'
export { Body } from './Body'
export { Html } from './Html'
diff --git a/packages/vue-router/src/ssr/RouterServer.tsx b/packages/vue-router/src/ssr/RouterServer.tsx
index a802165c4df..914461685c2 100644
--- a/packages/vue-router/src/ssr/RouterServer.tsx
+++ b/packages/vue-router/src/ssr/RouterServer.tsx
@@ -1,6 +1,6 @@
import * as Vue from 'vue'
import { Asset } from '../Asset'
-import { useTags } from '../HeadContent'
+import { useTags } from '../headContentUtils'
import { RouterProvider } from '../RouterProvider'
import { Scripts } from '../Scripts'
import type { AnyRouter, RouterManagedTag } from '@tanstack/router-core'
diff --git a/packages/vue-router/vite.config.ts b/packages/vue-router/vite.config.ts
index ac1212099b6..9985afd9f4a 100644
--- a/packages/vue-router/vite.config.ts
+++ b/packages/vue-router/vite.config.ts
@@ -24,7 +24,12 @@ const config = defineConfig({
export default mergeConfig(
config,
tanstackViteConfig({
- entry: ['./src/index.tsx', './src/ssr/client.ts', './src/ssr/server.ts'],
+ entry: [
+ './src/index.tsx',
+ './src/index.dev.tsx',
+ './src/ssr/client.ts',
+ './src/ssr/server.ts',
+ ],
srcDir: './src',
cjs: false,
}),