diff --git a/docs/start/framework/react/client-entry-point.md b/docs/start/framework/react/client-entry-point.md
index 02d0ecd426f..4e143fcb4d6 100644
--- a/docs/start/framework/react/client-entry-point.md
+++ b/docs/start/framework/react/client-entry-point.md
@@ -70,4 +70,46 @@ hydrateRoot(
)
```
+## Custom `useId` prefix
+
+You can customize the prefix of react's `useId` hook.
+
+```tsx
+// src/client.tsx
+import { StartClient } from '@tanstack/react-start/client'
+import { StrictMode } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { ErrorBoundary } from './components/ErrorBoundary'
+
+hydrateRoot(
+ document,
+
+
+
+
+ ,
+ {
+ identifierPrefix: 'custom-prefix', // ⚠️ These values must be the identical to prevent hydration errors!
+ },
+)
+```
+
+```tsx
+// src/router.tsx
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ const router = createRouter({
+ routeTree,
+ scrollRestoration: true,
+ ssr: {
+ identifierPrefix: 'custom-prefix', // ⚠️ These values must be the identical to prevent hydration errors!
+ },
+ })
+
+ return router
+}
+```
+
The client entry point gives you full control over how your application initializes on the client side while working seamlessly with TanStack Start's server-side rendering.
diff --git a/e2e/react-start/custom-identifier-prefix/.gitignore b/e2e/react-start/custom-identifier-prefix/.gitignore
new file mode 100644
index 00000000000..a79d5cf1299
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/.gitignore
@@ -0,0 +1,20 @@
+node_modules
+package-lock.json
+yarn.lock
+
+.DS_Store
+.cache
+.env
+.vercel
+.output
+
+/build/
+/api/
+/server/build
+/public/build
+# Sentry Config File
+.env.sentry-build-plugin
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/react-start/custom-identifier-prefix/.prettierignore b/e2e/react-start/custom-identifier-prefix/.prettierignore
new file mode 100644
index 00000000000..2be5eaa6ece
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/.prettierignore
@@ -0,0 +1,4 @@
+**/build
+**/public
+pnpm-lock.yaml
+routeTree.gen.ts
\ No newline at end of file
diff --git a/e2e/react-start/custom-identifier-prefix/package.json b/e2e/react-start/custom-identifier-prefix/package.json
new file mode 100644
index 00000000000..a7bd1764e9b
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "tanstack-react-start-e2e-custom-identifier-prefix",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "dev:e2e": "vite dev",
+ "build": "vite build && tsc --noEmit",
+ "build:spa": "MODE=spa vite build && tsc --noEmit",
+ "start": "pnpx srvx --prod -s ../client dist/server/server.js",
+ "start:spa": "node server.js",
+ "test:e2e:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium",
+ "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium",
+ "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:*",
+ "@tanstack/react-router-devtools": "workspace:*",
+ "@tanstack/react-start": "workspace:*",
+ "express": "^5.1.0",
+ "http-proxy-middleware": "^3.0.5",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "redaxios": "^0.5.1",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tanstack/router-e2e-utils": "workspace:*",
+ "@types/node": "^22.10.2",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "combinate": "^1.1.11",
+ "postcss": "^8.5.1",
+ "srvx": "^0.8.6",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "vite-tsconfig-paths": "^5.1.4",
+ "zod": "^3.24.2"
+ }
+}
diff --git a/e2e/react-start/custom-identifier-prefix/playwright.config.ts b/e2e/react-start/custom-identifier-prefix/playwright.config.ts
new file mode 100644
index 00000000000..ef3f68c5edf
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/playwright.config.ts
@@ -0,0 +1,60 @@
+import { defineConfig, devices } from '@playwright/test'
+import {
+ getDummyServerPort,
+ getTestServerPort,
+} from '@tanstack/router-e2e-utils'
+import { isSpaMode } from './tests/utils/isSpaMode'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(
+ `${packageJson.name}${isSpaMode ? '_spa' : ''}`,
+)
+const START_PORT = await getTestServerPort(
+ `${packageJson.name}${isSpaMode ? '_spa_start' : ''}`,
+)
+const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+const spaModeCommand = `pnpm build:spa && pnpm start:spa`
+const ssrModeCommand = `pnpm build && pnpm start`
+
+console.log('running in spa mode: ', isSpaMode.toString())
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+
+ globalSetup: './tests/setup/global.setup.ts',
+ globalTeardown: './tests/setup/global.teardown.ts',
+
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL,
+ },
+
+ webServer: {
+ command: isSpaMode ? spaModeCommand : ssrModeCommand,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ env: {
+ MODE: process.env.MODE || '',
+ VITE_NODE_ENV: 'test',
+ VITE_EXTERNAL_PORT: String(EXTERNAL_PORT),
+ VITE_SERVER_PORT: String(PORT),
+ START_PORT: String(START_PORT),
+ PORT: String(PORT),
+ },
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+ ],
+})
diff --git a/e2e/react-start/custom-identifier-prefix/postcss.config.mjs b/e2e/react-start/custom-identifier-prefix/postcss.config.mjs
new file mode 100644
index 00000000000..2e7af2b7f1a
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/e2e/react-start/custom-identifier-prefix/public/android-chrome-192x192.png b/e2e/react-start/custom-identifier-prefix/public/android-chrome-192x192.png
new file mode 100644
index 00000000000..09c8324f8c6
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/android-chrome-192x192.png differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/android-chrome-512x512.png b/e2e/react-start/custom-identifier-prefix/public/android-chrome-512x512.png
new file mode 100644
index 00000000000..11d626ea3d0
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/android-chrome-512x512.png differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/apple-touch-icon.png b/e2e/react-start/custom-identifier-prefix/public/apple-touch-icon.png
new file mode 100644
index 00000000000..5a9423cc02c
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/apple-touch-icon.png differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon-16x16.png b/e2e/react-start/custom-identifier-prefix/public/favicon-16x16.png
new file mode 100644
index 00000000000..e3389b00443
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon-16x16.png differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon-32x32.png b/e2e/react-start/custom-identifier-prefix/public/favicon-32x32.png
new file mode 100644
index 00000000000..900c77d444c
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon-32x32.png differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon.ico b/e2e/react-start/custom-identifier-prefix/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon.ico differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon.png b/e2e/react-start/custom-identifier-prefix/public/favicon.png
new file mode 100644
index 00000000000..1e77bc06091
Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon.png differ
diff --git a/e2e/react-start/custom-identifier-prefix/public/script.js b/e2e/react-start/custom-identifier-prefix/public/script.js
new file mode 100644
index 00000000000..897477e7d0a
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/public/script.js
@@ -0,0 +1,2 @@
+console.log('SCRIPT_1 loaded')
+window.SCRIPT_1 = true
diff --git a/e2e/react-start/custom-identifier-prefix/public/script2.js b/e2e/react-start/custom-identifier-prefix/public/script2.js
new file mode 100644
index 00000000000..819af30daf9
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/public/script2.js
@@ -0,0 +1,2 @@
+console.log('SCRIPT_2 loaded')
+window.SCRIPT_2 = true
diff --git a/e2e/react-start/custom-identifier-prefix/public/site.webmanifest b/e2e/react-start/custom-identifier-prefix/public/site.webmanifest
new file mode 100644
index 00000000000..fa99de77db6
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/e2e/react-start/custom-identifier-prefix/server.js b/e2e/react-start/custom-identifier-prefix/server.js
new file mode 100644
index 00000000000..d618ab4bce3
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/server.js
@@ -0,0 +1,67 @@
+import { toNodeHandler } from 'srvx/node'
+import path from 'node:path'
+import express from 'express'
+import { createProxyMiddleware } from 'http-proxy-middleware'
+
+const port = process.env.PORT || 3000
+
+const startPort = process.env.START_PORT || 3001
+
+export async function createStartServer() {
+ const server = (await import('./dist/server/server.js')).default
+ const nodeHandler = toNodeHandler(server.fetch)
+
+ const app = express()
+
+ app.use(express.static('./dist/client'))
+
+ app.use(async (req, res, next) => {
+ try {
+ await nodeHandler(req, res)
+ } catch (error) {
+ next(error)
+ }
+ })
+
+ return { app }
+}
+
+export async function createSpaServer() {
+ const app = express()
+
+ app.use(
+ '/api',
+ createProxyMiddleware({
+ target: `http://localhost:${startPort}/api`, // Replace with your target server's URL
+ changeOrigin: false, // Needed for virtual hosted sites,
+ }),
+ )
+
+ app.use(
+ '/_serverFn',
+ createProxyMiddleware({
+ target: `http://localhost:${startPort}/_serverFn`, // Replace with your target server's URL
+ changeOrigin: false, // Needed for virtual hosted sites,
+ }),
+ )
+
+ app.use(express.static('./dist/client'))
+
+ app.get('/{*splat}', (req, res) => {
+ res.sendFile(path.resolve('./dist/client/index.html'))
+ })
+
+ return { app }
+}
+
+createSpaServer().then(async ({ app }) =>
+ app.listen(port, () => {
+ console.info(`Client Server: http://localhost:${port}`)
+ }),
+)
+
+createStartServer().then(async ({ app }) =>
+ app.listen(startPort, () => {
+ console.info(`Start Server: http://localhost:${startPort}`)
+ }),
+)
diff --git a/e2e/react-start/custom-identifier-prefix/src/client.tsx b/e2e/react-start/custom-identifier-prefix/src/client.tsx
new file mode 100644
index 00000000000..9a554e0474e
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/client.tsx
@@ -0,0 +1,19 @@
+// DO NOT DELETE THIS FILE!!!
+// This file is a good smoke test to make sure the custom client entry is working
+import { StrictMode, startTransition } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { StartClient } from '@tanstack/react-start/client'
+
+console.log("[client-entry]: using custom client entry in 'src/client.tsx'")
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ {
+ identifierPrefix: 'myapp',
+ },
+ )
+})
diff --git a/e2e/react-start/custom-identifier-prefix/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/custom-identifier-prefix/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000000..15f316681cc
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from '@tanstack/react-router'
+import type { ErrorComponentProps } from '@tanstack/react-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+
+ {isRoot ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/components/NotFound.tsx b/e2e/react-start/custom-identifier-prefix/src/components/NotFound.tsx
new file mode 100644
index 00000000000..af4e0e74946
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/react-router'
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/routeTree.gen.ts b/e2e/react-start/custom-identifier-prefix/src/routeTree.gen.ts
new file mode 100644
index 00000000000..9ee945d10b2
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/routeTree.gen.ts
@@ -0,0 +1,68 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as IndexRouteImport } from './routes/index'
+
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/'
+ id: '__root__' | '/'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/router.tsx b/e2e/react-start/custom-identifier-prefix/src/router.tsx
new file mode 100644
index 00000000000..1c257ebf903
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/router.tsx
@@ -0,0 +1,19 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ const router = createRouter({
+ routeTree,
+ scrollRestoration: true,
+ defaultPreload: 'intent',
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ ssr: {
+ identifierPrefix: 'myapp',
+ },
+ })
+
+ return router
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/routes/__root.tsx b/e2e/react-start/custom-identifier-prefix/src/routes/__root.tsx
new file mode 100644
index 00000000000..8e645b76127
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/routes/__root.tsx
@@ -0,0 +1,123 @@
+///
+import * as React from 'react'
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+import appCss from '~/styles/app.css?url'
+import { seo } from '~/utils/seo'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ {
+ charSet: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ ...seo({
+ title:
+ 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
+ description: `TanStack Start is a type-safe, client-first, full-stack React framework. `,
+ }),
+ ],
+ links: [
+ { rel: 'stylesheet', href: appCss },
+ {
+ rel: 'apple-touch-icon',
+ sizes: '180x180',
+ href: '/apple-touch-icon.png',
+ },
+ {
+ rel: 'icon',
+ type: 'image/png',
+ sizes: '32x32',
+ href: '/favicon-32x32.png',
+ },
+ {
+ rel: 'icon',
+ type: 'image/png',
+ sizes: '16x16',
+ href: '/favicon-16x16.png',
+ },
+ { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
+ { rel: 'icon', href: '/favicon.ico' },
+ ],
+ styles: [
+ {
+ media: 'all and (min-width: 500px)',
+ children: `
+ .inline-div {
+ color: white;
+ background-color: gray;
+ max-width: 250px;
+ }`,
+ },
+ ],
+ }),
+ errorComponent: (props) => {
+ return (
+
+
+
+ )
+ },
+ notFoundComponent: () => ,
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+ )
+}
+
+const RouterDevtools =
+ process.env.NODE_ENV === 'production'
+ ? () => null // Render nothing in production
+ : React.lazy(() =>
+ // Lazy load in development
+ import('@tanstack/react-router-devtools').then((res) => ({
+ default: res.TanStackRouterDevtools,
+ })),
+ )
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ Home
+
+
+
+ {children}
+ This is an inline styled div
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/routes/index.tsx b/e2e/react-start/custom-identifier-prefix/src/routes/index.tsx
new file mode 100644
index 00000000000..9fe9f0c230d
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/routes/index.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { useId } from 'react'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ const id = useId()
+
+ return (
+
+
{id}
+
+ )
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/server.ts b/e2e/react-start/custom-identifier-prefix/src/server.ts
new file mode 100644
index 00000000000..00d13b4d178
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/server.ts
@@ -0,0 +1,11 @@
+// DO NOT DELETE THIS FILE!!!
+// This file is a good smoke test to make sure the custom server entry is working
+import handler from '@tanstack/react-start/server-entry'
+
+console.log("[server-entry]: using custom server entry in 'src/server.ts'")
+
+export default {
+ fetch(request: Request) {
+ return handler.fetch(request)
+ },
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/styles/app.css b/e2e/react-start/custom-identifier-prefix/src/styles/app.css
new file mode 100644
index 00000000000..c53c8706654
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/styles/app.css
@@ -0,0 +1,22 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ html {
+ color-scheme: light dark;
+ }
+
+ * {
+ @apply border-gray-200 dark:border-gray-800;
+ }
+
+ html,
+ body {
+ @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200;
+ }
+
+ .using-mouse * {
+ outline: none !important;
+ }
+}
diff --git a/e2e/react-start/custom-identifier-prefix/src/utils/seo.ts b/e2e/react-start/custom-identifier-prefix/src/utils/seo.ts
new file mode 100644
index 00000000000..2d1cf2ea5d4
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/src/utils/seo.ts
@@ -0,0 +1,37 @@
+export const seo = ({
+ title,
+ description,
+ keywords,
+ image,
+}: {
+ title: string
+ description?: string
+ image?: string
+ keywords?: string
+}) => {
+ const tags = [
+ { title },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:creator', content: '@tannerlinsley' },
+ { name: 'twitter:site', content: '@tannerlinsley' },
+ { name: 'og:type', content: 'website' },
+ { name: 'og:title', content: title },
+ ...(description
+ ? [
+ { name: 'description', content: description },
+ { name: 'twitter:description', content: description },
+ { name: 'og:description', content: description },
+ ]
+ : []),
+ ...(keywords ? [{ name: 'keywords', content: keywords }] : []),
+ ...(image
+ ? [
+ { name: 'twitter:image', content: image },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'og:image', content: image },
+ ]
+ : []),
+ ]
+
+ return tags
+}
diff --git a/e2e/react-start/custom-identifier-prefix/tailwind.config.mjs b/e2e/react-start/custom-identifier-prefix/tailwind.config.mjs
new file mode 100644
index 00000000000..e49f4eb776e
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/tailwind.config.mjs
@@ -0,0 +1,4 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./src/**/*.{js,jsx,ts,tsx}'],
+}
diff --git a/e2e/react-start/custom-identifier-prefix/tests/identifier-prefix.spec.ts b/e2e/react-start/custom-identifier-prefix/tests/identifier-prefix.spec.ts
new file mode 100644
index 00000000000..8ad90c10b08
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/tests/identifier-prefix.spec.ts
@@ -0,0 +1,15 @@
+import { expect } from '@playwright/test'
+
+import { test } from '@tanstack/router-e2e-utils'
+
+test.use({
+ whitelistErrors: [
+ /Failed to load resource: the server responded with a status of 404/,
+ ],
+})
+
+test('Custom identifier prefix', async ({ page }) => {
+ await page.goto('/')
+
+ await expect(page.getByTestId('tested-element')).toContainText('myapp')
+})
diff --git a/e2e/react-start/custom-identifier-prefix/tests/setup/global.setup.ts b/e2e/react-start/custom-identifier-prefix/tests/setup/global.setup.ts
new file mode 100644
index 00000000000..3593d10ab90
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/tests/setup/global.setup.ts
@@ -0,0 +1,6 @@
+import { e2eStartDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function setup() {
+ await e2eStartDummyServer(packageJson.name)
+}
diff --git a/e2e/react-start/custom-identifier-prefix/tests/setup/global.teardown.ts b/e2e/react-start/custom-identifier-prefix/tests/setup/global.teardown.ts
new file mode 100644
index 00000000000..62fd79911cc
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/tests/setup/global.teardown.ts
@@ -0,0 +1,6 @@
+import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function teardown() {
+ await e2eStopDummyServer(packageJson.name)
+}
diff --git a/e2e/react-start/custom-identifier-prefix/tests/utils/isSpaMode.ts b/e2e/react-start/custom-identifier-prefix/tests/utils/isSpaMode.ts
new file mode 100644
index 00000000000..b4edb829a8f
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/tests/utils/isSpaMode.ts
@@ -0,0 +1 @@
+export const isSpaMode: boolean = process.env.MODE === 'spa'
diff --git a/e2e/react-start/custom-identifier-prefix/tsconfig.json b/e2e/react-start/custom-identifier-prefix/tsconfig.json
new file mode 100644
index 00000000000..b3a2d67dfa6
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["**/*.ts", "**/*.tsx", "public/script*.js"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/e2e/react-start/custom-identifier-prefix/vite.config.ts b/e2e/react-start/custom-identifier-prefix/vite.config.ts
new file mode 100644
index 00000000000..b91cd686950
--- /dev/null
+++ b/e2e/react-start/custom-identifier-prefix/vite.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteReact from '@vitejs/plugin-react'
+import { isSpaMode } from './tests/utils/isSpaMode'
+
+const spaModeConfiguration = {
+ enabled: true,
+ prerender: {
+ outputPath: 'index.html',
+ },
+}
+
+export default defineConfig({
+ server: {
+ port: 3000,
+ },
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ // @ts-ignore we want to keep one test with verboseFileRoutes off even though the option is hidden
+ tanstackStart({
+ spa: isSpaMode ? spaModeConfiguration : undefined,
+ }),
+ viteReact(),
+ ],
+})
diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx
index 0c96c2f4c53..a9fcdcec55a 100644
--- a/packages/react-router/src/ssr/renderRouterToStream.tsx
+++ b/packages/react-router/src/ssr/renderRouterToStream.tsx
@@ -24,6 +24,7 @@ export const renderRouterToStream = async ({
const stream = await ReactDOMServer.renderToReadableStream(children, {
signal: request.signal,
nonce: router.options.ssr?.nonce,
+ identifierPrefix: router.options.ssr?.identifierPrefix,
})
if (isbot(request.headers.get('User-Agent'))) {
@@ -46,6 +47,7 @@ export const renderRouterToStream = async ({
try {
const pipeable = ReactDOMServer.renderToPipeableStream(children, {
nonce: router.options.ssr?.nonce,
+ identifierPrefix: router.options.ssr?.identifierPrefix,
...(isbot(request.headers.get('User-Agent'))
? {
onAllReady() {
diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx
index b75e88e78e8..fb61bcdb905 100644
--- a/packages/react-router/src/ssr/renderRouterToString.tsx
+++ b/packages/react-router/src/ssr/renderRouterToString.tsx
@@ -12,7 +12,9 @@ export const renderRouterToString = async ({
children: ReactNode
}) => {
try {
- let html = ReactDOMServer.renderToString(children)
+ let html = ReactDOMServer.renderToString(children, {
+ identifierPrefix: router.options.ssr?.identifierPrefix,
+ })
router.serverSsr!.setRenderFinished()
const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then(
(htmls) => htmls.join(''),
diff --git a/packages/react-router/src/ssr/serializer.ts b/packages/react-router/src/ssr/serializer.ts
index 9a1743c18a8..b9db7457d15 100644
--- a/packages/react-router/src/ssr/serializer.ts
+++ b/packages/react-router/src/ssr/serializer.ts
@@ -4,4 +4,8 @@ declare module '@tanstack/router-core' {
export interface SerializerExtensions {
ReadableStream: React.JSX.Element
}
+
+ interface RouterSSROptionsExtensions {
+ identifierPrefix?: string
+ }
}
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index 50273b30e0a..2c43db25f0e 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -244,6 +244,7 @@ export type {
ClearCacheFn,
CreateRouterFn,
SSROption,
+ RouterSSROptionsExtensions,
} from './router'
export * from './config'
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 78b5e11608b..54baa52d87f 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -136,6 +136,10 @@ export interface RouterOptionsExtensions
export type SSROption = boolean | 'data-only'
+export interface RouterSSROptionsExtensions {
+ nonce?: string
+}
+
export interface RouterOptions<
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption,
@@ -465,9 +469,7 @@ export interface RouterOptions<
*/
rewrite?: LocationRewrite
origin?: string
- ssr?: {
- nonce?: string
- }
+ ssr?: RouterSSROptionsExtensions
}
export type LocationRewrite = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ad8e1808efd..158514e2b63 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1366,6 +1366,82 @@ importers:
specifier: ^5.1.4
version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))
+ e2e/react-start/custom-identifier-prefix:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ '@tanstack/react-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/react-router-devtools
+ '@tanstack/react-start':
+ specifier: workspace:*
+ version: link:../../../packages/react-start
+ express:
+ specifier: ^5.1.0
+ version: 5.1.0
+ http-proxy-middleware:
+ specifier: ^3.0.5
+ version: 3.0.5
+ react:
+ specifier: ^19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.0.0(react@19.0.0)
+ redaxios:
+ specifier: ^0.5.1
+ version: 0.5.1
+ tailwind-merge:
+ specifier: ^2.6.0
+ version: 2.6.0
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.52.0
+ version: 1.52.0
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:*
+ version: link:../../e2e-utils
+ '@types/node':
+ specifier: 22.10.2
+ version: 22.10.2
+ '@types/react':
+ specifier: ^19.0.8
+ version: 19.0.8
+ '@types/react-dom':
+ specifier: ^19.0.3
+ version: 19.0.3(@types/react@19.0.8)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.4.20(postcss@8.5.6)
+ combinate:
+ specifier: ^1.1.11
+ version: 1.1.11
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.6
+ srvx:
+ specifier: ^0.8.6
+ version: 0.8.7
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.17
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.2
+ vite:
+ specifier: ^7.1.7
+ version: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
+ vite-tsconfig-paths:
+ specifier: ^5.1.4
+ version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))
+ zod:
+ specifier: ^3.24.2
+ version: 3.25.57
+
e2e/react-start/query-integration:
dependencies:
'@tanstack/react-query':
@@ -5346,7 +5422,7 @@ importers:
version: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
vitest:
specifier: ^3.2.4
- version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
+ version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
web-vitals:
specifier: ^5.1.0
version: 5.1.0
@@ -27349,7 +27425,7 @@ snapshots:
optionalDependencies:
vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
- vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0):
+ vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
@@ -27378,7 +27454,7 @@ snapshots:
'@types/node': 22.10.2
'@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4)
'@vitest/ui': 3.0.6(vitest@3.2.4)
- jsdom: 27.0.0(postcss@8.5.6)
+ jsdom: 25.0.1
transitivePeerDependencies:
- jiti
- less
@@ -27393,7 +27469,7 @@ snapshots:
- tsx
- yaml
- vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0):
+ vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
@@ -27422,7 +27498,7 @@ snapshots:
'@types/node': 22.10.2
'@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4)
'@vitest/ui': 3.0.6(vitest@3.2.4)
- jsdom: 25.0.1
+ jsdom: 27.0.0(postcss@8.5.6)
transitivePeerDependencies:
- jiti
- less