From 895d7ca135b795780fc95f7fce17e62df672ccf9 Mon Sep 17 00:00:00 2001
From: Jakub Sedlacek <sedlacek.jakub@gmail.com>
Date: Thu, 28 Aug 2025 22:52:01 +0200
Subject: [PATCH 1/4] test for order of meta and TSR

---
 .../basic/tests/charset-encoding.spec.ts      | 33 +++++++++++++++++++
 1 file changed, 33 insertions(+)
 create mode 100644 e2e/react-start/basic/tests/charset-encoding.spec.ts

diff --git a/e2e/react-start/basic/tests/charset-encoding.spec.ts b/e2e/react-start/basic/tests/charset-encoding.spec.ts
new file mode 100644
index 0000000000..cff7ef4e11
--- /dev/null
+++ b/e2e/react-start/basic/tests/charset-encoding.spec.ts
@@ -0,0 +1,33 @@
+import { expect } from '@playwright/test'
+import { test } from './fixture'
+
+test.describe('Charset Encoding', () => {
+  test('documents charset meta tag ordering issue in server-rendered HTML', async ({
+    page,
+  }) => {
+    // Navigate to a server-rendered page to get the HTML with dehydration scripts
+    const response = await page.goto('/')
+    const html = await response?.text()
+
+    expect(html).toBeDefined()
+    if (!html) return
+
+    // Case-insensitive search for charset meta and TSR script
+    const htmlLower = html.toLowerCase()
+    const charsetIndex = htmlLower.search(/<meta\s+charset=(["'])utf-8\1/)
+    const tsrScriptIndex = htmlLower.search(/<script\s+class=(["'])\$tsr\1/)
+
+    // Both should exist in server-rendered HTML with dehydration
+    expect(charsetIndex).toBeGreaterThan(-1)
+    expect(tsrScriptIndex).toBeGreaterThan(-1)
+
+    // CURRENT ISSUE: TSR dehydration script appears BEFORE charset meta tag
+    // This can cause encoding issues when the script contains non-ASCII characters
+    // According to HTML5 spec, charset should appear before any content requiring encoding
+    //
+    // This test documents the current (incorrect) behavior
+    expect(tsrScriptIndex).toBeLessThan(charsetIndex)
+
+    // TODO: Once fixed, change to: expect(charsetIndex).toBeLessThan(tsrScriptIndex)
+  })
+})

From 2e34ff920c2199f3acf374240a8e447884905212 Mon Sep 17 00:00:00 2001
From: Jakub Sedlacek <sedlacek.jakub@gmail.com>
Date: Thu, 28 Aug 2025 22:45:31 +0200
Subject: [PATCH 2/4] charset issue fix

---
 .../basic/tests/charset-encoding.spec.ts      | 13 ++----
 .../src/ssr/transformStreamWithRouter.ts      | 44 ++++++++++++++-----
 2 files changed, 36 insertions(+), 21 deletions(-)

diff --git a/e2e/react-start/basic/tests/charset-encoding.spec.ts b/e2e/react-start/basic/tests/charset-encoding.spec.ts
index cff7ef4e11..011c323c01 100644
--- a/e2e/react-start/basic/tests/charset-encoding.spec.ts
+++ b/e2e/react-start/basic/tests/charset-encoding.spec.ts
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
 import { test } from './fixture'
 
 test.describe('Charset Encoding', () => {
-  test('documents charset meta tag ordering issue in server-rendered HTML', async ({
+  test('asserts charset meta tag appears before dehydration script', async ({
     page,
   }) => {
     // Navigate to a server-rendered page to get the HTML with dehydration scripts
@@ -21,13 +21,8 @@ test.describe('Charset Encoding', () => {
     expect(charsetIndex).toBeGreaterThan(-1)
     expect(tsrScriptIndex).toBeGreaterThan(-1)
 
-    // CURRENT ISSUE: TSR dehydration script appears BEFORE charset meta tag
-    // This can cause encoding issues when the script contains non-ASCII characters
-    // According to HTML5 spec, charset should appear before any content requiring encoding
-    //
-    // This test documents the current (incorrect) behavior
-    expect(tsrScriptIndex).toBeLessThan(charsetIndex)
-
-    // TODO: Once fixed, change to: expect(charsetIndex).toBeLessThan(tsrScriptIndex)
+    // With the fix, the charset meta tag should now appear BEFORE the TSR dehydration script.
+    // This ensures correct character encoding and compliance with the HTML5 spec.
+    expect(charsetIndex).toBeLessThan(tsrScriptIndex)
   })
 })
diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts
index 54c0197bb5..25c6d86f57 100644
--- a/packages/router-core/src/ssr/transformStreamWithRouter.ts
+++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts
@@ -24,6 +24,8 @@ const patternBodyStart = /(<body)/
 const patternBodyEnd = /(<\/body>)/
 const patternHtmlEnd = /(<\/html>)/
 const patternHeadStart = /(<head.*?>)/
+const patternHeadEnd = /(<\/head>)/
+const patternCharset = /(<meta\s+charset=(["']?)[^"'>\s]+\2.*?>)/i
 // regex pattern for matching closing tags
 const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g
 
@@ -98,6 +100,7 @@ export function transformStreamWithRouter(
   let pendingClosingTags = ''
   let bodyStarted = false as boolean
   let headStarted = false as boolean
+  let headScriptInjected = false as boolean
   let leftover = ''
   let leftoverHtml = ''
 
@@ -181,18 +184,35 @@ export function transformStreamWithRouter(
         }
       }
 
-      if (!headStarted) {
-        const headStartMatch = chunkString.match(patternHeadStart)
-        if (headStartMatch) {
-          headStarted = true
-          const index = headStartMatch.index!
-          const headTag = headStartMatch[0]
-          const remaining = chunkString.slice(index + headTag.length)
-          finalPassThrough.write(
-            chunkString.slice(0, index) + headTag + getBufferedRouterStream(),
-          )
-          // make sure to only write `remaining` until the next closing tag
-          chunkString = remaining
+      if (!headScriptInjected && !bodyStarted) {
+        if (!headStarted) {
+          const headStartMatch = chunkString.match(patternHeadStart)
+          if (headStartMatch) {
+            headStarted = true
+          }
+        }
+
+        if (headStarted) {
+          const charsetMatch = chunkString.match(patternCharset)
+
+          if (charsetMatch) {
+            headScriptInjected = true
+            const index = charsetMatch.index! + charsetMatch[0]!.length
+            finalPassThrough.write(
+              chunkString.slice(0, index) + getBufferedRouterStream(),
+            )
+            chunkString = chunkString.slice(index)
+          } else {
+            const headEndMatch = chunkString.match(patternHeadEnd)
+            if (headEndMatch) {
+              headScriptInjected = true
+              const index = headEndMatch.index!
+              finalPassThrough.write(
+                chunkString.slice(0, index) + getBufferedRouterStream(),
+              )
+              chunkString = chunkString.slice(index)
+            }
+          }
         }
       }
 

From c8f7a3ed5b97c9cc82163793108abaeab7af6f2a Mon Sep 17 00:00:00 2001
From: Jakub Sedlacek <sedlacek.jakub@gmail.com>
Date: Fri, 29 Aug 2025 11:36:40 +0200
Subject: [PATCH 3/4] fix TSR position when no charset meta

---
 packages/router-core/src/ssr/transformStreamWithRouter.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts
index 25c6d86f57..2383fbbb96 100644
--- a/packages/router-core/src/ssr/transformStreamWithRouter.ts
+++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts
@@ -184,7 +184,7 @@ export function transformStreamWithRouter(
         }
       }
 
-      if (!headScriptInjected && !bodyStarted) {
+      if (!headScriptInjected) {
         if (!headStarted) {
           const headStartMatch = chunkString.match(patternHeadStart)
           if (headStartMatch) {

From c75089d31820eaa9cbe8ed4aac211956e188055e Mon Sep 17 00:00:00 2001
From: Jakub Sedlacek <sedlacek.jakub@gmail.com>
Date: Fri, 29 Aug 2025 12:32:56 +0200
Subject: [PATCH 4/4] encoding test package

---
 .../basic/tests/charset-encoding.spec.ts      |  28 ----
 e2e/react-start/encoding/.gitignore           |  20 +++
 e2e/react-start/encoding/.prettierignore      |   4 +
 e2e/react-start/encoding/package.json         |  38 +++++
 e2e/react-start/encoding/playwright.config.ts |  42 +++++
 e2e/react-start/encoding/postcss.config.mjs   |   6 +
 e2e/react-start/encoding/src/client.tsx       |  19 +++
 .../encoding/src/components/CustomMessage.tsx |  10 ++
 .../src/components/DefaultCatchBoundary.tsx   |  53 ++++++
 .../encoding/src/components/NotFound.tsx      |  25 +++
 e2e/react-start/encoding/src/routeTree.gen.ts | 153 ++++++++++++++++++
 e2e/react-start/encoding/src/router.tsx       |  22 +++
 .../encoding/src/routes/__root.tsx            |  64 ++++++++
 .../encoding/src/routes/charset.tsx           |  14 ++
 e2e/react-start/encoding/src/routes/index.tsx |  14 ++
 .../encoding/src/routes/no-charset.tsx        |   7 +
 e2e/react-start/encoding/src/server.ts        |  13 ++
 e2e/react-start/encoding/tailwind.config.mjs  |   4 +
 .../encoding/tests/charset-encoding.spec.ts   |  52 ++++++
 e2e/react-start/encoding/tests/fixture.ts     |  28 ++++
 .../encoding/tests/setup/global.setup.ts      |   6 +
 .../encoding/tests/setup/global.teardown.ts   |   6 +
 e2e/react-start/encoding/tsconfig.json        |  22 +++
 e2e/react-start/encoding/vite.config.ts       |  15 ++
 24 files changed, 637 insertions(+), 28 deletions(-)
 delete mode 100644 e2e/react-start/basic/tests/charset-encoding.spec.ts
 create mode 100644 e2e/react-start/encoding/.gitignore
 create mode 100644 e2e/react-start/encoding/.prettierignore
 create mode 100644 e2e/react-start/encoding/package.json
 create mode 100644 e2e/react-start/encoding/playwright.config.ts
 create mode 100644 e2e/react-start/encoding/postcss.config.mjs
 create mode 100644 e2e/react-start/encoding/src/client.tsx
 create mode 100644 e2e/react-start/encoding/src/components/CustomMessage.tsx
 create mode 100644 e2e/react-start/encoding/src/components/DefaultCatchBoundary.tsx
 create mode 100644 e2e/react-start/encoding/src/components/NotFound.tsx
 create mode 100644 e2e/react-start/encoding/src/routeTree.gen.ts
 create mode 100644 e2e/react-start/encoding/src/router.tsx
 create mode 100644 e2e/react-start/encoding/src/routes/__root.tsx
 create mode 100644 e2e/react-start/encoding/src/routes/charset.tsx
 create mode 100644 e2e/react-start/encoding/src/routes/index.tsx
 create mode 100644 e2e/react-start/encoding/src/routes/no-charset.tsx
 create mode 100644 e2e/react-start/encoding/src/server.ts
 create mode 100644 e2e/react-start/encoding/tailwind.config.mjs
 create mode 100644 e2e/react-start/encoding/tests/charset-encoding.spec.ts
 create mode 100644 e2e/react-start/encoding/tests/fixture.ts
 create mode 100644 e2e/react-start/encoding/tests/setup/global.setup.ts
 create mode 100644 e2e/react-start/encoding/tests/setup/global.teardown.ts
 create mode 100644 e2e/react-start/encoding/tsconfig.json
 create mode 100644 e2e/react-start/encoding/vite.config.ts

diff --git a/e2e/react-start/basic/tests/charset-encoding.spec.ts b/e2e/react-start/basic/tests/charset-encoding.spec.ts
deleted file mode 100644
index 011c323c01..0000000000
--- a/e2e/react-start/basic/tests/charset-encoding.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { expect } from '@playwright/test'
-import { test } from './fixture'
-
-test.describe('Charset Encoding', () => {
-  test('asserts charset meta tag appears before dehydration script', async ({
-    page,
-  }) => {
-    // Navigate to a server-rendered page to get the HTML with dehydration scripts
-    const response = await page.goto('/')
-    const html = await response?.text()
-
-    expect(html).toBeDefined()
-    if (!html) return
-
-    // Case-insensitive search for charset meta and TSR script
-    const htmlLower = html.toLowerCase()
-    const charsetIndex = htmlLower.search(/<meta\s+charset=(["'])utf-8\1/)
-    const tsrScriptIndex = htmlLower.search(/<script\s+class=(["'])\$tsr\1/)
-
-    // Both should exist in server-rendered HTML with dehydration
-    expect(charsetIndex).toBeGreaterThan(-1)
-    expect(tsrScriptIndex).toBeGreaterThan(-1)
-
-    // With the fix, the charset meta tag should now appear BEFORE the TSR dehydration script.
-    // This ensures correct character encoding and compliance with the HTML5 spec.
-    expect(charsetIndex).toBeLessThan(tsrScriptIndex)
-  })
-})
diff --git a/e2e/react-start/encoding/.gitignore b/e2e/react-start/encoding/.gitignore
new file mode 100644
index 0000000000..a79d5cf129
--- /dev/null
+++ b/e2e/react-start/encoding/.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/encoding/.prettierignore b/e2e/react-start/encoding/.prettierignore
new file mode 100644
index 0000000000..2be5eaa6ec
--- /dev/null
+++ b/e2e/react-start/encoding/.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/encoding/package.json b/e2e/react-start/encoding/package.json
new file mode 100644
index 0000000000..acfc1a2346
--- /dev/null
+++ b/e2e/react-start/encoding/package.json
@@ -0,0 +1,38 @@
+{
+  "name": "tanstack-react-start-e2e-encoding",
+  "private": true,
+  "sideEffects": false,
+  "type": "module",
+  "scripts": {
+    "dev": "vite dev --port 3000",
+    "dev:e2e": "vite dev",
+    "build": "vite build && tsc --noEmit",
+    "start": "node .output/server/index.mjs",
+    "test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
+  },
+  "dependencies": {
+    "@tanstack/react-router": "workspace:^",
+    "@tanstack/react-router-devtools": "workspace:^",
+    "@tanstack/react-start": "workspace:^",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "redaxios": "^0.5.1",
+    "tailwind-merge": "^2.6.0",
+    "vite": "6.3.5",
+    "zod": "^3.24.2"
+  },
+  "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",
+    "tailwindcss": "^3.4.17",
+    "typescript": "^5.7.2",
+    "vite-tsconfig-paths": "^5.1.4"
+  }
+}
diff --git a/e2e/react-start/encoding/playwright.config.ts b/e2e/react-start/encoding/playwright.config.ts
new file mode 100644
index 0000000000..b0c365f8bd
--- /dev/null
+++ b/e2e/react-start/encoding/playwright.config.ts
@@ -0,0 +1,42 @@
+import { defineConfig, devices } from '@playwright/test'
+import {
+  getDummyServerPort,
+  getTestServerPort,
+} from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+
+/**
+ * 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: `VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`,
+    url: baseURL,
+    reuseExistingServer: !process.env.CI,
+    stdout: 'pipe',
+  },
+
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+})
diff --git a/e2e/react-start/encoding/postcss.config.mjs b/e2e/react-start/encoding/postcss.config.mjs
new file mode 100644
index 0000000000..2e7af2b7f1
--- /dev/null
+++ b/e2e/react-start/encoding/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}
diff --git a/e2e/react-start/encoding/src/client.tsx b/e2e/react-start/encoding/src/client.tsx
new file mode 100644
index 0000000000..5fcd1c5f19
--- /dev/null
+++ b/e2e/react-start/encoding/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'
+import { createRouter } from './router'
+
+console.log("[client-entry]: using custom client entry in 'src/client.tsx'")
+
+const router = createRouter()
+
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <StartClient router={router} />
+    </StrictMode>,
+  )
+})
diff --git a/e2e/react-start/encoding/src/components/CustomMessage.tsx b/e2e/react-start/encoding/src/components/CustomMessage.tsx
new file mode 100644
index 0000000000..d00e4eac60
--- /dev/null
+++ b/e2e/react-start/encoding/src/components/CustomMessage.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react'
+
+export function CustomMessage({ message }: { message: string }) {
+  return (
+    <div className="py-2">
+      <div className="italic">This is a custom message:</div>
+      <p>{message}</p>
+    </div>
+  )
+}
diff --git a/e2e/react-start/encoding/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/encoding/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 0000000000..15f316681c
--- /dev/null
+++ b/e2e/react-start/encoding/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 (
+    <div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
+      <ErrorComponent error={error} />
+      <div className="flex gap-2 items-center flex-wrap">
+        <button
+          onClick={() => {
+            router.invalidate()
+          }}
+          className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
+        >
+          Try Again
+        </button>
+        {isRoot ? (
+          <Link
+            to="/"
+            className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
+          >
+            Home
+          </Link>
+        ) : (
+          <Link
+            to="/"
+            className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
+            onClick={(e) => {
+              e.preventDefault()
+              window.history.back()
+            }}
+          >
+            Go Back
+          </Link>
+        )}
+      </div>
+    </div>
+  )
+}
diff --git a/e2e/react-start/encoding/src/components/NotFound.tsx b/e2e/react-start/encoding/src/components/NotFound.tsx
new file mode 100644
index 0000000000..af4e0e7494
--- /dev/null
+++ b/e2e/react-start/encoding/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/react-router'
+
+export function NotFound({ children }: { children?: any }) {
+  return (
+    <div className="space-y-2 p-2" data-testid="default-not-found-component">
+      <div className="text-gray-600 dark:text-gray-400">
+        {children || <p>The page you are looking for does not exist.</p>}
+      </div>
+      <p className="flex items-center gap-2 flex-wrap">
+        <button
+          onClick={() => window.history.back()}
+          className="bg-emerald-500 text-white px-2 py-1 rounded uppercase font-black text-sm"
+        >
+          Go back
+        </button>
+        <Link
+          to="/"
+          className="bg-cyan-600 text-white px-2 py-1 rounded uppercase font-black text-sm"
+        >
+          Start Over
+        </Link>
+      </p>
+    </div>
+  )
+}
diff --git a/e2e/react-start/encoding/src/routeTree.gen.ts b/e2e/react-start/encoding/src/routeTree.gen.ts
new file mode 100644
index 0000000000..2ca196a7ca
--- /dev/null
+++ b/e2e/react-start/encoding/src/routeTree.gen.ts
@@ -0,0 +1,153 @@
+/* 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 type { CreateFileRoute, FileRoutesByPath } from '@tanstack/react-router'
+import type {
+  CreateServerFileRoute,
+  ServerFileRoutesByPath,
+} from '@tanstack/react-start/server'
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as NoCharsetRouteImport } from './routes/no-charset'
+import { Route as CharsetRouteImport } from './routes/charset'
+import { Route as IndexRouteImport } from './routes/index'
+
+const NoCharsetRoute = NoCharsetRouteImport.update({
+  id: '/no-charset',
+  path: '/no-charset',
+  getParentRoute: () => rootRouteImport,
+} as any)
+const CharsetRoute = CharsetRouteImport.update({
+  id: '/charset',
+  path: '/charset',
+  getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+  id: '/',
+  path: '/',
+  getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+  '/': typeof IndexRoute
+  '/charset': typeof CharsetRoute
+  '/no-charset': typeof NoCharsetRoute
+}
+export interface FileRoutesByTo {
+  '/': typeof IndexRoute
+  '/charset': typeof CharsetRoute
+  '/no-charset': typeof NoCharsetRoute
+}
+export interface FileRoutesById {
+  __root__: typeof rootRouteImport
+  '/': typeof IndexRoute
+  '/charset': typeof CharsetRoute
+  '/no-charset': typeof NoCharsetRoute
+}
+export interface FileRouteTypes {
+  fileRoutesByFullPath: FileRoutesByFullPath
+  fullPaths: '/' | '/charset' | '/no-charset'
+  fileRoutesByTo: FileRoutesByTo
+  to: '/' | '/charset' | '/no-charset'
+  id: '__root__' | '/' | '/charset' | '/no-charset'
+  fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+  IndexRoute: typeof IndexRoute
+  CharsetRoute: typeof CharsetRoute
+  NoCharsetRoute: typeof NoCharsetRoute
+}
+
+declare module '@tanstack/react-router' {
+  interface FileRoutesByPath {
+    '/': {
+      id: '/'
+      path: '/'
+      fullPath: '/'
+      preLoaderRoute: typeof IndexRouteImport
+      parentRoute: typeof rootRouteImport
+    }
+    '/charset': {
+      id: '/charset'
+      path: '/charset'
+      fullPath: '/charset'
+      preLoaderRoute: typeof CharsetRouteImport
+      parentRoute: typeof rootRouteImport
+    }
+    '/no-charset': {
+      id: '/no-charset'
+      path: '/no-charset'
+      fullPath: '/no-charset'
+      preLoaderRoute: typeof NoCharsetRouteImport
+      parentRoute: typeof rootRouteImport
+    }
+  }
+}
+
+declare module './routes/index' {
+  const createFileRoute: CreateFileRoute<
+    '/',
+    FileRoutesByPath['/']['parentRoute'],
+    FileRoutesByPath['/']['id'],
+    FileRoutesByPath['/']['path'],
+    FileRoutesByPath['/']['fullPath']
+  >
+
+  const createServerFileRoute: CreateServerFileRoute<
+    ServerFileRoutesByPath['/']['parentRoute'],
+    ServerFileRoutesByPath['/']['id'],
+    ServerFileRoutesByPath['/']['path'],
+    ServerFileRoutesByPath['/']['fullPath'],
+    unknown
+  >
+}
+declare module './routes/charset' {
+  const createFileRoute: CreateFileRoute<
+    '/charset',
+    FileRoutesByPath['/charset']['parentRoute'],
+    FileRoutesByPath['/charset']['id'],
+    FileRoutesByPath['/charset']['path'],
+    FileRoutesByPath['/charset']['fullPath']
+  >
+
+  const createServerFileRoute: CreateServerFileRoute<
+    ServerFileRoutesByPath['/charset']['parentRoute'],
+    ServerFileRoutesByPath['/charset']['id'],
+    ServerFileRoutesByPath['/charset']['path'],
+    ServerFileRoutesByPath['/charset']['fullPath'],
+    unknown
+  >
+}
+declare module './routes/no-charset' {
+  const createFileRoute: CreateFileRoute<
+    '/no-charset',
+    FileRoutesByPath['/no-charset']['parentRoute'],
+    FileRoutesByPath['/no-charset']['id'],
+    FileRoutesByPath['/no-charset']['path'],
+    FileRoutesByPath['/no-charset']['fullPath']
+  >
+
+  const createServerFileRoute: CreateServerFileRoute<
+    ServerFileRoutesByPath['/no-charset']['parentRoute'],
+    ServerFileRoutesByPath['/no-charset']['id'],
+    ServerFileRoutesByPath['/no-charset']['path'],
+    ServerFileRoutesByPath['/no-charset']['fullPath'],
+    unknown
+  >
+}
+
+const rootRouteChildren: RootRouteChildren = {
+  IndexRoute: IndexRoute,
+  CharsetRoute: CharsetRoute,
+  NoCharsetRoute: NoCharsetRoute,
+}
+export const routeTree = rootRouteImport
+  ._addFileChildren(rootRouteChildren)
+  ._addFileTypes<FileRouteTypes>()
diff --git a/e2e/react-start/encoding/src/router.tsx b/e2e/react-start/encoding/src/router.tsx
new file mode 100644
index 0000000000..c76eb0210c
--- /dev/null
+++ b/e2e/react-start/encoding/src/router.tsx
@@ -0,0 +1,22 @@
+import { createRouter as createTanStackRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function createRouter() {
+  const router = createTanStackRouter({
+    routeTree,
+    defaultPreload: 'intent',
+    defaultErrorComponent: DefaultCatchBoundary,
+    defaultNotFoundComponent: () => <NotFound />,
+    scrollRestoration: true,
+  })
+
+  return router
+}
+
+declare module '@tanstack/react-router' {
+  interface Register {
+    router: ReturnType<typeof createRouter>
+  }
+}
diff --git a/e2e/react-start/encoding/src/routes/__root.tsx b/e2e/react-start/encoding/src/routes/__root.tsx
new file mode 100644
index 0000000000..388c3cbfe6
--- /dev/null
+++ b/e2e/react-start/encoding/src/routes/__root.tsx
@@ -0,0 +1,64 @@
+/// <reference types="vite/client" />
+import * as React from 'react'
+import {
+  HeadContent,
+  Outlet,
+  Scripts,
+  createRootRoute,
+} from '@tanstack/react-router'
+
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+
+export const Route = createRootRoute({
+  head: () => ({
+    meta: [
+      {
+        name: 'viewport',
+        content: 'width=device-width, initial-scale=1',
+      },
+    ],
+  }),
+  errorComponent: (props) => {
+    return (
+      <RootDocument>
+        <DefaultCatchBoundary {...props} />
+      </RootDocument>
+    )
+  },
+  notFoundComponent: () => <NotFound />,
+  component: RootComponent,
+})
+
+function RootComponent() {
+  return (
+    <RootDocument>
+      <Outlet />
+    </RootDocument>
+  )
+}
+
+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 (
+    <html>
+      <head>
+        <HeadContent />
+      </head>
+      <body>
+        {children}
+        <RouterDevtools position="bottom-right" />
+        <Scripts />
+      </body>
+    </html>
+  )
+}
diff --git a/e2e/react-start/encoding/src/routes/charset.tsx b/e2e/react-start/encoding/src/routes/charset.tsx
new file mode 100644
index 0000000000..b522ef192a
--- /dev/null
+++ b/e2e/react-start/encoding/src/routes/charset.tsx
@@ -0,0 +1,14 @@
+export const Route = createFileRoute({
+  head: () => ({
+    meta: [
+      {
+        charSet: 'utf-8',
+      },
+    ],
+  }),
+  component: Page,
+})
+
+function Page() {
+  return <h1>Charset</h1>
+}
diff --git a/e2e/react-start/encoding/src/routes/index.tsx b/e2e/react-start/encoding/src/routes/index.tsx
new file mode 100644
index 0000000000..b522ef192a
--- /dev/null
+++ b/e2e/react-start/encoding/src/routes/index.tsx
@@ -0,0 +1,14 @@
+export const Route = createFileRoute({
+  head: () => ({
+    meta: [
+      {
+        charSet: 'utf-8',
+      },
+    ],
+  }),
+  component: Page,
+})
+
+function Page() {
+  return <h1>Charset</h1>
+}
diff --git a/e2e/react-start/encoding/src/routes/no-charset.tsx b/e2e/react-start/encoding/src/routes/no-charset.tsx
new file mode 100644
index 0000000000..4ef0458325
--- /dev/null
+++ b/e2e/react-start/encoding/src/routes/no-charset.tsx
@@ -0,0 +1,7 @@
+export const Route = createFileRoute({
+  component: Page,
+})
+
+function Page() {
+  return <h1>No charset</h1>
+}
diff --git a/e2e/react-start/encoding/src/server.ts b/e2e/react-start/encoding/src/server.ts
new file mode 100644
index 0000000000..77cb4a1316
--- /dev/null
+++ b/e2e/react-start/encoding/src/server.ts
@@ -0,0 +1,13 @@
+// DO NOT DELETE THIS FILE!!!
+// This file is a good smoke test to make sure the custom server entry is working
+import {
+  createStartHandler,
+  defaultStreamHandler,
+} from '@tanstack/react-start/server'
+import { createRouter } from './router'
+
+console.log("[server-entry]: using custom server entry in 'src/server.ts'")
+
+export default createStartHandler({
+  createRouter,
+})(defaultStreamHandler)
diff --git a/e2e/react-start/encoding/tailwind.config.mjs b/e2e/react-start/encoding/tailwind.config.mjs
new file mode 100644
index 0000000000..e49f4eb776
--- /dev/null
+++ b/e2e/react-start/encoding/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/encoding/tests/charset-encoding.spec.ts b/e2e/react-start/encoding/tests/charset-encoding.spec.ts
new file mode 100644
index 0000000000..8b949e4697
--- /dev/null
+++ b/e2e/react-start/encoding/tests/charset-encoding.spec.ts
@@ -0,0 +1,52 @@
+import { expect } from '@playwright/test'
+import { test } from './fixture'
+
+test.describe('Encoding', () => {
+  test('asserts dehydration script is injected before </head> when no charset is present', async ({
+    page,
+  }) => {
+    // In this test setup, the no-charset route does not have a charset meta tag.
+    const response = await page.goto('/no-charset')
+    const html = await response?.text()
+
+    expect(html).toBeDefined()
+    if (!html) return
+
+    // Case-insensitive search for charset meta, TSR script, and closing head tag
+    const htmlLower = html.toLowerCase()
+    const charsetIndex = htmlLower.search(/<meta\s+charset=(["'])utf-8\1/)
+    const tsrScriptIndex = htmlLower.search(/<script\s+class=(["'])\$tsr\1/)
+    const headEndIndex = htmlLower.indexOf('</head>')
+
+    // Charset should NOT exist, but the TSR script and head tag should
+    expect(charsetIndex).toBe(-1)
+    expect(tsrScriptIndex).toBeGreaterThan(-1)
+    expect(headEndIndex).toBeGreaterThan(-1)
+
+    // In the fallback case, the TSR dehydration script should appear BEFORE the closing </head> tag.
+    expect(tsrScriptIndex).toBeLessThan(headEndIndex)
+  })
+
+  test('asserts charset meta tag appears before dehydration script when present on a route', async ({
+    page,
+  }) => {
+    // This route specifically adds a charset meta tag.
+    const response = await page.goto('/charset')
+    const html = await response?.text()
+
+    expect(html).toBeDefined()
+    if (!html) return
+
+    // Case-insensitive search for charset meta and TSR script
+    const htmlLower = html.toLowerCase()
+    const charsetIndex = htmlLower.search(/<meta\s+charset=(["'])utf-8\1/)
+    const tsrScriptIndex = htmlLower.search(/<script\s+class=(["'])\$tsr\1/)
+
+    // Both should exist in server-rendered HTML with dehydration
+    expect(charsetIndex).toBeGreaterThan(-1)
+    expect(tsrScriptIndex).toBeGreaterThan(-1)
+
+    // The charset meta tag should appear BEFORE the TSR dehydration script.
+    expect(charsetIndex).toBeLessThan(tsrScriptIndex)
+  })
+})
diff --git a/e2e/react-start/encoding/tests/fixture.ts b/e2e/react-start/encoding/tests/fixture.ts
new file mode 100644
index 0000000000..abb7b1d564
--- /dev/null
+++ b/e2e/react-start/encoding/tests/fixture.ts
@@ -0,0 +1,28 @@
+import { test as base, expect } from '@playwright/test'
+
+export interface TestFixtureOptions {
+  whitelistErrors: Array<RegExp | string>
+}
+export const test = base.extend<TestFixtureOptions>({
+  whitelistErrors: [[], { option: true }],
+  page: async ({ page, whitelistErrors }, use) => {
+    const errorMessages: Array<string> = []
+    page.on('console', (m) => {
+      if (m.type() === 'error') {
+        const text = m.text()
+        for (const whitelistError of whitelistErrors) {
+          if (
+            (typeof whitelistError === 'string' &&
+              text.includes(whitelistError)) ||
+            (whitelistError instanceof RegExp && whitelistError.test(text))
+          ) {
+            return
+          }
+        }
+        errorMessages.push(text)
+      }
+    })
+    await use(page)
+    expect(errorMessages).toEqual([])
+  },
+})
diff --git a/e2e/react-start/encoding/tests/setup/global.setup.ts b/e2e/react-start/encoding/tests/setup/global.setup.ts
new file mode 100644
index 0000000000..3593d10ab9
--- /dev/null
+++ b/e2e/react-start/encoding/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/encoding/tests/setup/global.teardown.ts b/e2e/react-start/encoding/tests/setup/global.teardown.ts
new file mode 100644
index 0000000000..62fd79911c
--- /dev/null
+++ b/e2e/react-start/encoding/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/encoding/tsconfig.json b/e2e/react-start/encoding/tsconfig.json
new file mode 100644
index 0000000000..b3a2d67dfa
--- /dev/null
+++ b/e2e/react-start/encoding/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/encoding/vite.config.ts b/e2e/react-start/encoding/vite.config.ts
new file mode 100644
index 0000000000..5443069826
--- /dev/null
+++ b/e2e/react-start/encoding/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+
+export default defineConfig({
+  server: {
+    port: 3000,
+  },
+  plugins: [
+    tsConfigPaths({
+      projects: ['./tsconfig.json'],
+    }),
+    tanstackStart({ tsr: { verboseFileRoutes: false } }),
+  ],
+})
