diff --git a/.changeset/gentle-hoops-shave.md b/.changeset/gentle-hoops-shave.md
new file mode 100644
index 00000000000..e2e8c12e6e4
--- /dev/null
+++ b/.changeset/gentle-hoops-shave.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/router-core': patch
+---
+
+allow Temporal types in serializable data
diff --git a/e2e/react-start/serialization-adapters/package.json b/e2e/react-start/serialization-adapters/package.json
index 0e0d157733c..27633b4e50d 100644
--- a/e2e/react-start/serialization-adapters/package.json
+++ b/e2e/react-start/serialization-adapters/package.json
@@ -17,6 +17,7 @@
"@tanstack/react-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "temporal-polyfill": "^1.0.4",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/e2e/react-start/serialization-adapters/src/client.tsx b/e2e/react-start/serialization-adapters/src/client.tsx
new file mode 100644
index 00000000000..4ba538c19b8
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/client.tsx
@@ -0,0 +1,15 @@
+import { StrictMode, startTransition } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { StartClient } from '@tanstack/react-start/client'
+import { install as installTemporalPolyfill } from 'temporal-polyfill/shim'
+
+installTemporalPolyfill()
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ )
+})
diff --git a/e2e/react-start/serialization-adapters/src/routeTree.gen.ts b/e2e/react-start/serialization-adapters/src/routeTree.gen.ts
index 1b21d4200b7..bb6c30b459f 100644
--- a/e2e/react-start/serialization-adapters/src/routeTree.gen.ts
+++ b/e2e/react-start/serialization-adapters/src/routeTree.gen.ts
@@ -13,9 +13,11 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as ServerFunctionCustomErrorRouteImport } from './routes/server-function/custom-error'
import { Route as ServerFunctionLateRawStreamRouteImport } from './routes/server-function/late-raw-stream'
import { Route as ServerFunctionNestedRouteImport } from './routes/server-function/nested'
+import { Route as ServerFunctionTemporalRouteImport } from './routes/server-function/temporal'
import { Route as SsrDataOnlyRouteImport } from './routes/ssr/data-only'
import { Route as SsrNestedRouteImport } from './routes/ssr/nested'
import { Route as SsrStreamRouteImport } from './routes/ssr/stream'
+import { Route as SsrTemporalRouteImport } from './routes/ssr/temporal'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -39,6 +41,11 @@ const ServerFunctionNestedRoute = ServerFunctionNestedRouteImport.update({
path: '/server-function/nested',
getParentRoute: () => rootRouteImport,
} as any)
+const ServerFunctionTemporalRoute = ServerFunctionTemporalRouteImport.update({
+ id: '/server-function/temporal',
+ path: '/server-function/temporal',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SsrDataOnlyRoute = SsrDataOnlyRouteImport.update({
id: '/ssr/data-only',
path: '/ssr/data-only',
@@ -54,24 +61,33 @@ const SsrStreamRoute = SsrStreamRouteImport.update({
path: '/ssr/stream',
getParentRoute: () => rootRouteImport,
} as any)
+const SsrTemporalRoute = SsrTemporalRouteImport.update({
+ id: '/ssr/temporal',
+ path: '/ssr/temporal',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/server-function/custom-error': typeof ServerFunctionCustomErrorRoute
'/server-function/late-raw-stream': typeof ServerFunctionLateRawStreamRoute
'/server-function/nested': typeof ServerFunctionNestedRoute
+ '/server-function/temporal': typeof ServerFunctionTemporalRoute
'/ssr/data-only': typeof SsrDataOnlyRoute
'/ssr/nested': typeof SsrNestedRoute
'/ssr/stream': typeof SsrStreamRoute
+ '/ssr/temporal': typeof SsrTemporalRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/server-function/custom-error': typeof ServerFunctionCustomErrorRoute
'/server-function/late-raw-stream': typeof ServerFunctionLateRawStreamRoute
'/server-function/nested': typeof ServerFunctionNestedRoute
+ '/server-function/temporal': typeof ServerFunctionTemporalRoute
'/ssr/data-only': typeof SsrDataOnlyRoute
'/ssr/nested': typeof SsrNestedRoute
'/ssr/stream': typeof SsrStreamRoute
+ '/ssr/temporal': typeof SsrTemporalRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -79,9 +95,11 @@ export interface FileRoutesById {
'/server-function/custom-error': typeof ServerFunctionCustomErrorRoute
'/server-function/late-raw-stream': typeof ServerFunctionLateRawStreamRoute
'/server-function/nested': typeof ServerFunctionNestedRoute
+ '/server-function/temporal': typeof ServerFunctionTemporalRoute
'/ssr/data-only': typeof SsrDataOnlyRoute
'/ssr/nested': typeof SsrNestedRoute
'/ssr/stream': typeof SsrStreamRoute
+ '/ssr/temporal': typeof SsrTemporalRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -90,27 +108,33 @@ export interface FileRouteTypes {
| '/server-function/custom-error'
| '/server-function/late-raw-stream'
| '/server-function/nested'
+ | '/server-function/temporal'
| '/ssr/data-only'
| '/ssr/nested'
| '/ssr/stream'
+ | '/ssr/temporal'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/server-function/custom-error'
| '/server-function/late-raw-stream'
| '/server-function/nested'
+ | '/server-function/temporal'
| '/ssr/data-only'
| '/ssr/nested'
| '/ssr/stream'
+ | '/ssr/temporal'
id:
| '__root__'
| '/'
| '/server-function/custom-error'
| '/server-function/late-raw-stream'
| '/server-function/nested'
+ | '/server-function/temporal'
| '/ssr/data-only'
| '/ssr/nested'
| '/ssr/stream'
+ | '/ssr/temporal'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -118,9 +142,11 @@ export interface RootRouteChildren {
ServerFunctionCustomErrorRoute: typeof ServerFunctionCustomErrorRoute
ServerFunctionLateRawStreamRoute: typeof ServerFunctionLateRawStreamRoute
ServerFunctionNestedRoute: typeof ServerFunctionNestedRoute
+ ServerFunctionTemporalRoute: typeof ServerFunctionTemporalRoute
SsrDataOnlyRoute: typeof SsrDataOnlyRoute
SsrNestedRoute: typeof SsrNestedRoute
SsrStreamRoute: typeof SsrStreamRoute
+ SsrTemporalRoute: typeof SsrTemporalRoute
}
declare module '@tanstack/react-router' {
@@ -153,6 +179,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ServerFunctionNestedRouteImport
parentRoute: typeof rootRouteImport
}
+ '/server-function/temporal': {
+ id: '/server-function/temporal'
+ path: '/server-function/temporal'
+ fullPath: '/server-function/temporal'
+ preLoaderRoute: typeof ServerFunctionTemporalRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/ssr/data-only': {
id: '/ssr/data-only'
path: '/ssr/data-only'
@@ -174,6 +207,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SsrStreamRouteImport
parentRoute: typeof rootRouteImport
}
+ '/ssr/temporal': {
+ id: '/ssr/temporal'
+ path: '/ssr/temporal'
+ fullPath: '/ssr/temporal'
+ preLoaderRoute: typeof SsrTemporalRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -182,9 +222,11 @@ const rootRouteChildren: RootRouteChildren = {
ServerFunctionCustomErrorRoute: ServerFunctionCustomErrorRoute,
ServerFunctionLateRawStreamRoute: ServerFunctionLateRawStreamRoute,
ServerFunctionNestedRoute: ServerFunctionNestedRoute,
+ ServerFunctionTemporalRoute: ServerFunctionTemporalRoute,
SsrDataOnlyRoute: SsrDataOnlyRoute,
SsrNestedRoute: SsrNestedRoute,
SsrStreamRoute: SsrStreamRoute,
+ SsrTemporalRoute: SsrTemporalRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/e2e/react-start/serialization-adapters/src/routes/index.tsx b/e2e/react-start/serialization-adapters/src/routes/index.tsx
index e39d32cd639..f4b8b98c5b6 100644
--- a/e2e/react-start/serialization-adapters/src/routes/index.tsx
+++ b/e2e/react-start/serialization-adapters/src/routes/index.tsx
@@ -32,6 +32,14 @@ function Home() {
>
Nested Classes
+
+
+ Temporal
+
Server Functions
@@ -50,6 +58,14 @@ function Home() {
>
Nested Classes returned from Server Function
+
+
+ Temporal returned from Server Function
+
>
)
diff --git a/e2e/react-start/serialization-adapters/src/routes/server-function/temporal.tsx b/e2e/react-start/serialization-adapters/src/routes/server-function/temporal.tsx
new file mode 100644
index 00000000000..ac07bb6e533
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/routes/server-function/temporal.tsx
@@ -0,0 +1,34 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useState } from 'react'
+import type { TemporalData } from '~/temporal'
+import { RenderTemporalData, makeTemporalData } from '~/temporal'
+
+const temporalFn = createServerFn().handler(() => {
+ return makeTemporalData()
+})
+
+export const Route = createFileRoute('/server-function/temporal')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ const [temporalResponse, setTemporalResponse] = useState()
+
+ return (
+
+
+
+ {temporalResponse ? (
+
+ ) : (
+
waiting for response...
+ )}
+
+ )
+}
diff --git a/e2e/react-start/serialization-adapters/src/routes/ssr/temporal.tsx b/e2e/react-start/serialization-adapters/src/routes/ssr/temporal.tsx
new file mode 100644
index 00000000000..683bb3e67df
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/routes/ssr/temporal.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { RenderTemporalData, makeTemporalData } from '~/temporal'
+
+export const Route = createFileRoute('/ssr/temporal')({
+ loader: () => makeTemporalData(),
+ component: () => {
+ const loaderData = Route.useLoaderData()
+
+ return
+ },
+})
diff --git a/e2e/react-start/serialization-adapters/src/server.ts b/e2e/react-start/serialization-adapters/src/server.ts
new file mode 100644
index 00000000000..1777d7ee92d
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/server.ts
@@ -0,0 +1,10 @@
+import handler from '@tanstack/react-start/server-entry'
+import { install as installTemporalPolyfill } from 'temporal-polyfill/shim'
+
+installTemporalPolyfill()
+
+export default {
+ fetch(request: Request) {
+ return handler.fetch(request)
+ },
+}
diff --git a/e2e/react-start/serialization-adapters/src/temporal-global.d.ts b/e2e/react-start/serialization-adapters/src/temporal-global.d.ts
new file mode 100644
index 00000000000..a29f92fe362
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/temporal-global.d.ts
@@ -0,0 +1 @@
+import 'temporal-polyfill/types/global'
diff --git a/e2e/react-start/serialization-adapters/src/temporal.tsx b/e2e/react-start/serialization-adapters/src/temporal.tsx
new file mode 100644
index 00000000000..2885cda72c3
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/temporal.tsx
@@ -0,0 +1,56 @@
+export const temporalTypeNames = [
+ 'Instant',
+ 'Duration',
+ 'PlainDate',
+ 'PlainDateTime',
+ 'PlainMonthDay',
+ 'PlainTime',
+ 'PlainYearMonth',
+ 'ZonedDateTime',
+] as const
+
+export function makeTemporalData() {
+ return {
+ Instant: Temporal.Instant.from('2024-03-05T06:07:08.9Z'),
+ Duration: Temporal.Duration.from('P1Y2M3DT4H5M6.7S'),
+ PlainDate: Temporal.PlainDate.from('2024-03-05'),
+ PlainDateTime: Temporal.PlainDateTime.from('2024-03-05T06:07:08.9'),
+ PlainMonthDay: Temporal.PlainMonthDay.from('03-05'),
+ PlainTime: Temporal.PlainTime.from('06:07:08.9'),
+ PlainYearMonth: Temporal.PlainYearMonth.from('2024-03'),
+ ZonedDateTime: Temporal.ZonedDateTime.from(
+ '2024-03-05T06:07:08.9+01:00[Europe/Berlin]',
+ ),
+ }
+}
+
+export type TemporalData = ReturnType
+
+function formatTemporal(value: unknown) {
+ return `${Object.prototype.toString.call(value)} ${String(value)}`
+}
+
+export function RenderTemporalData({
+ id,
+ data,
+}: {
+ id: string
+ data: TemporalData
+}) {
+ const localData = makeTemporalData()
+ return (
+
+ {temporalTypeNames.map((name) => (
+
+
{name}
+
+ {formatTemporal(localData[name])}
+
+
+ {formatTemporal(data[name])}
+
+
+ ))}
+
+ )
+}
diff --git a/e2e/react-start/serialization-adapters/tests/app.spec.ts b/e2e/react-start/serialization-adapters/tests/app.spec.ts
index 26f70e81b07..ebec811e9db 100644
--- a/e2e/react-start/serialization-adapters/tests/app.spec.ts
+++ b/e2e/react-start/serialization-adapters/tests/app.spec.ts
@@ -39,6 +39,29 @@ async function checkNestedData(page: Page) {
expectedWhisper!,
)
}
+
+const temporalTypeNames = [
+ 'Instant',
+ 'Duration',
+ 'PlainDate',
+ 'PlainDateTime',
+ 'PlainMonthDay',
+ 'PlainTime',
+ 'PlainYearMonth',
+ 'ZonedDateTime',
+] as const
+
+async function checkTemporalData(page: Page, id: string) {
+ for (const name of temporalTypeNames) {
+ const expected = await page
+ .getByTestId(`${id}-${name}-expected`)
+ .textContent()
+ expect(expected).not.toBeNull()
+ expect(expected).toContain(`[object Temporal.${name}]`)
+ await expect(page.getByTestId(`${id}-${name}-actual`)).toHaveText(expected!)
+ }
+}
+
test.use({
whitelistErrors: [
'Failed to load resource: the server responded with a status of 499',
@@ -74,6 +97,13 @@ test.describe('SSR serialization adapters', () => {
await checkNestedData(page)
})
+
+ test('temporal', async ({ page }) => {
+ await page.goto('/ssr/temporal')
+ await awaitPageLoaded(page)
+
+ await checkTemporalData(page, 'loader')
+ })
})
test.describe('server functions serialization adapters', () => {
@@ -109,6 +139,18 @@ test.describe('server functions serialization adapters', () => {
await page.getByTestId('server-function-trigger').click()
await checkNestedData(page)
})
+
+ test('temporal', async ({ page }) => {
+ await page.goto('/server-function/temporal')
+ await awaitPageLoaded(page)
+
+ await expect(page.getByTestId('waiting-for-response')).toContainText(
+ 'waiting for response...',
+ )
+
+ await page.getByTestId('server-function-trigger').click()
+ await checkTemporalData(page, 'server-fn')
+ })
})
test.describe('late RawStream serialization', () => {
diff --git a/packages/router-core/src/ssr/serializer/transformer.ts b/packages/router-core/src/ssr/serializer/transformer.ts
index 6eeed9b6e66..f300a8aa306 100644
--- a/packages/router-core/src/ssr/serializer/transformer.ts
+++ b/packages/router-core/src/ssr/serializer/transformer.ts
@@ -15,6 +15,20 @@ export type TSR_SERIALIZABLE = typeof TSR_SERIALIZABLE
export type TsrSerializable = { [TSR_SERIALIZABLE]: true }
+// Resolve `Temporal` from `globalThis` so it degrades to `never`, not `any`,
+// for consumers whose `lib` predates or omits `ESNext.Temporal`.
+type TemporalNamespace = typeof globalThis extends { Temporal: infer TTemporal }
+ ? TTemporal
+ : Record
+
+type TemporalInstance =
+ TemporalNamespace extends Record<
+ TKey,
+ abstract new (...args: any) => infer TInstance
+ >
+ ? TInstance
+ : never
+
export interface DefaultSerializable {
number: number
string: string
@@ -26,6 +40,14 @@ export interface DefaultSerializable {
Uint8Array: Uint8Array
RawStream: RawStream
TsrSerializable: TsrSerializable
+ TemporalInstant: TemporalInstance<'Instant'>
+ TemporalDuration: TemporalInstance<'Duration'>
+ TemporalPlainDate: TemporalInstance<'PlainDate'>
+ TemporalPlainDateTime: TemporalInstance<'PlainDateTime'>
+ TemporalPlainMonthDay: TemporalInstance<'PlainMonthDay'>
+ TemporalPlainTime: TemporalInstance<'PlainTime'>
+ TemporalPlainYearMonth: TemporalInstance<'PlainYearMonth'>
+ TemporalZonedDateTime: TemporalInstance<'ZonedDateTime'>
void: void
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b5f19618d3f..724de523a2d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3308,6 +3308,9 @@ importers:
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
+ temporal-polyfill:
+ specifier: ^1.0.4
+ version: 1.0.4
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -27575,6 +27578,15 @@ packages:
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
+ temporal-polyfill@1.0.4:
+ resolution: {integrity: sha512-MLEU0qOD2uXlz24oINNtdLZQl8RgmMxSnRtKEGZGGetTJjlRwQJjk+VsJ4EREaUoiaTb8NJOcUiKtoAiWn9EBg==}
+
+ temporal-spec@1.0.1:
+ resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
+
+ temporal-utils@1.0.2:
+ resolution: {integrity: sha512-1B8Dl4KzrOvsNUlpoWGno2VLQlxroLjDgc5NBjVC9ax9ymdo+ezfyvhyjEMx+IVfhINr6CIG2gcnlP95iRrybQ==}
+
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
@@ -42425,6 +42437,15 @@ snapshots:
transitivePeerDependencies:
- bare-abort-controller
+ temporal-polyfill@1.0.4:
+ dependencies:
+ temporal-spec: 1.0.1
+ temporal-utils: 1.0.2
+
+ temporal-spec@1.0.1: {}
+
+ temporal-utils@1.0.2: {}
+
term-size@2.2.1: {}
terser-webpack-plugin@5.3.11(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.27.4)(webpack@5.97.1):