diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx
index 7a81b87f173..b5d671456ad 100644
--- a/packages/react-router/src/Asset.tsx
+++ b/packages/react-router/src/Asset.tsx
@@ -49,8 +49,15 @@ function Script({
children?: string
}) {
const router = useRouter()
+ const dataScript =
+ typeof attrs?.type === 'string' &&
+ attrs.type !== '' &&
+ attrs.type !== 'text/javascript' &&
+ attrs.type !== 'module'
React.useEffect(() => {
+ if (dataScript) return
+
if (attrs?.src) {
const normSrc = (() => {
try {
@@ -142,9 +149,19 @@ function Script({
}
return undefined
- }, [attrs, children])
+ }, [attrs, children, dataScript])
if (!(isServer ?? router.isServer)) {
+ if (dataScript && typeof children === 'string') {
+ return (
+
+ )
+ }
+
const { src, ...rest } = attrs || {}
// render an empty script on the client just to avoid hydration errors
return (
diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx
index 315b3e528e5..1c92e4b2203 100644
--- a/packages/react-router/tests/Scripts.test.tsx
+++ b/packages/react-router/tests/Scripts.test.tsx
@@ -221,3 +221,351 @@ describe('ssr HeadContent', () => {
)
})
})
+
+describe('data script rendering', () => {
+ test('data script renders content on server (SSR)', async () => {
+ const jsonLd = JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: 'Test Article',
+ })
+
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ type: 'application/ld+json',
+ children: jsonLd,
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ isServer: true,
+ })
+
+ await router.load()
+
+ const html = ReactDOMServer.renderToString(
+ ,
+ )
+
+ expect(html).toContain('application/ld+json')
+ expect(html).toContain(jsonLd)
+ })
+
+ test('data script preserves content on client', async () => {
+ const jsonLd = JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ name: 'Test',
+ })
+
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ type: 'application/ld+json',
+ children: jsonLd,
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ await router.load()
+
+ const { container } = await act(() =>
+ render(),
+ )
+
+ const rootEl = container.querySelector('[data-testid="data-client-root"]')
+ expect(rootEl).not.toBeNull()
+
+ const scriptEl = container.querySelector(
+ 'script[type="application/ld+json"]',
+ )
+ expect(scriptEl).not.toBeNull()
+ expect(scriptEl!.innerHTML).toBe(jsonLd)
+ })
+
+ test('executable script still renders empty on client', async () => {
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ children: 'console.log("hello")',
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ await router.load()
+
+ const { container } = await act(() =>
+ render(),
+ )
+
+ const rootEl = container.querySelector('[data-testid="exec-root"]')
+ expect(rootEl).not.toBeNull()
+
+ const scripts = container.querySelectorAll('script:not([type])')
+ const inlineScript = Array.from(scripts).find((s) => !s.hasAttribute('src'))
+ expect(inlineScript).not.toBeNull()
+ // Executable scripts should render empty (content applied via useEffect)
+ expect(inlineScript!.innerHTML).toBe('')
+ })
+
+ test('module script still renders empty on client', async () => {
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ type: 'module',
+ children: 'import { foo } from "./foo.js"',
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ await router.load()
+
+ const { container } = await act(() =>
+ render(),
+ )
+
+ const rootEl = container.querySelector('[data-testid="module-root"]')
+ expect(rootEl).not.toBeNull()
+
+ const moduleScript = container.querySelector('script[type="module"]')
+ expect(moduleScript).not.toBeNull()
+ // Module scripts should render empty (content applied via useEffect)
+ expect(moduleScript!.innerHTML).toBe('')
+ })
+
+ test('application/json data script preserves content', async () => {
+ const jsonData = JSON.stringify({ config: { theme: 'dark' } })
+
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ type: 'application/json',
+ children: jsonData,
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ await router.load()
+
+ const { container } = await act(() =>
+ render(),
+ )
+
+ const rootEl = container.querySelector('[data-testid="json-root"]')
+ expect(rootEl).not.toBeNull()
+
+ const scriptEl = container.querySelector('script[type="application/json"]')
+ expect(scriptEl).not.toBeNull()
+ expect(scriptEl!.innerHTML).toBe(jsonData)
+ })
+
+ test('data script does not duplicate into document.head', async () => {
+ const jsonLd = JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ name: 'Test Org',
+ })
+
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ type: 'application/ld+json',
+ children: jsonLd,
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ await router.load()
+
+ const { container } = await act(() =>
+ render(),
+ )
+
+ const rootEl = container.querySelector('[data-testid="dup-root"]')
+ expect(rootEl).not.toBeNull()
+
+ // Data scripts should NOT be duplicated into document.head by useEffect
+ const headScripts = document.head.querySelectorAll(
+ 'script[type="application/ld+json"]',
+ )
+ expect(headScripts.length).toBe(0)
+
+ // Should only exist once in the container
+ const containerScripts = container.querySelectorAll(
+ 'script[type="application/ld+json"]',
+ )
+ expect(containerScripts.length).toBe(1)
+ expect(containerScripts[0]!.innerHTML).toBe(jsonLd)
+ })
+
+ test('empty string type is treated as executable, not data script', async () => {
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ {
+ type: '',
+ children: 'console.log("empty type")',
+ },
+ ],
+ component: () => {
+ return (
+
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: ['/'],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ await router.load()
+
+ const { container } = await act(() =>
+ render(),
+ )
+
+ const rootEl = container.querySelector('[data-testid="empty-type-root"]')
+ expect(rootEl).not.toBeNull()
+
+ const scriptEl = container.querySelector('script[type=""]')
+ expect(scriptEl).not.toBeNull()
+ // Empty type = text/javascript per HTML spec, should render empty like executable scripts
+ expect(scriptEl!.innerHTML).toBe('')
+ })
+})
diff --git a/packages/solid-router/src/Asset.tsx b/packages/solid-router/src/Asset.tsx
index 3d89c2e1d31..b762e7bb78a 100644
--- a/packages/solid-router/src/Asset.tsx
+++ b/packages/solid-router/src/Asset.tsx
@@ -39,8 +39,15 @@ function Script({
children?: string
}): JSX.Element | null {
const router = useRouter()
+ const dataScript =
+ typeof attrs?.type === 'string' &&
+ attrs.type !== '' &&
+ attrs.type !== 'text/javascript' &&
+ attrs.type !== 'module'
onMount(() => {
+ if (dataScript) return
+
if (attrs?.src) {
const normSrc = (() => {
try {
@@ -125,6 +132,10 @@ function Script({
})
if (!(isServer ?? router.isServer)) {
+ if (dataScript && typeof children === 'string') {
+ return
+ }
+
// render an empty script on the client just to avoid hydration errors
return null
}
diff --git a/packages/vue-router/src/Asset.tsx b/packages/vue-router/src/Asset.tsx
index 80f61398f93..128ffa8ad37 100644
--- a/packages/vue-router/src/Asset.tsx
+++ b/packages/vue-router/src/Asset.tsx
@@ -54,9 +54,16 @@ const Script = Vue.defineComponent({
},
setup(props) {
const router = useRouter()
+ const dataScript =
+ typeof props.attrs?.type === 'string' &&
+ props.attrs.type !== '' &&
+ props.attrs.type !== 'text/javascript' &&
+ props.attrs.type !== 'module'
if (!(isServer ?? router.isServer)) {
Vue.onMounted(() => {
+ if (dataScript) return
+
const attrs = props.attrs
const children = props.children
@@ -132,6 +139,14 @@ const Script = Vue.defineComponent({
return () => {
if (!(isServer ?? router.isServer)) {
+ if (dataScript && typeof props.children === 'string') {
+ return Vue.h('script', {
+ ...props.attrs,
+ 'data-allow-mismatch': true,
+ innerHTML: props.children,
+ })
+ }
+
const { src: _src, ...rest } = props.attrs || {}
return Vue.h('script', {
...rest,