diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml
new file mode 100644
index 0000000000..08f6e8dc98
--- /dev/null
+++ b/.github/workflows/bundle-size.yml
@@ -0,0 +1,93 @@
+name: Bundle Size
+
+on:
+  pull_request:
+  push:
+    branches: [main]
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.number || github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: write
+  pull-requests: write
+
+jobs:
+  benchmark-pr:
+    name: Benchmark PR
+    if: github.event_name == 'pull_request'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6.0.1
+        with:
+          fetch-depth: 0
+
+      - name: Setup Tools
+        uses: tanstack/config/.github/setup@main
+
+      - name: Measure Bundle Size
+        run: pnpm nx run tanstack-router-e2e-bundle-size:build --outputStyle=stream --skipRemoteCache
+
+      - name: Read Historical Data (if available)
+        run: |
+          mkdir -p e2e/bundle-size/results
+          if git fetch --depth=1 origin gh-pages; then
+            if git show origin/gh-pages:benchmarks/bundle-size/data.js > e2e/bundle-size/results/history-data.js 2>/dev/null; then
+              echo "Loaded bundle-size history from gh-pages."
+            else
+              rm -f e2e/bundle-size/results/history-data.js
+              echo "No bundle-size history found on gh-pages yet."
+            fi
+          fi
+
+      - name: Build PR Report
+        run: |
+          node scripts/benchmarks/bundle-size/pr-report.mjs \
+            --current e2e/bundle-size/results/current.json \
+            --history e2e/bundle-size/results/history-data.js \
+            --output e2e/bundle-size/results/pr-comment.md \
+            --base-sha "${{ github.event.pull_request.base.sha }}" \
+            --dashboard-url "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/benchmarks/bundle-size/"
+
+      - name: Upsert Sticky PR Comment
+        if: github.event.pull_request.head.repo.fork == false
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          node scripts/benchmarks/common/upsert-pr-comment.mjs \
+            --pr "${{ github.event.pull_request.number }}" \
+            --body-file e2e/bundle-size/results/pr-comment.md
+
+  benchmark-main:
+    name: Publish Bundle Size History
+    if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository_owner == 'TanStack'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6.0.1
+        with:
+          fetch-depth: 0
+
+      - name: Setup Tools
+        uses: tanstack/config/.github/setup@main
+
+      - name: Measure Bundle Size
+        run: pnpm nx run tanstack-router-e2e-bundle-size:build --outputStyle=stream --skipRemoteCache
+
+      - name: Publish Benchmark Dashboard
+        uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7
+        with:
+          tool: customSmallerIsBetter
+          benchmark-name: Bundle Size (gzip)
+          output-file-path: e2e/bundle-size/results/benchmark-action.json
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          auto-push: true
+          gh-pages-branch: gh-pages
+          benchmark-data-dir-path: benchmarks/bundle-size
+          max-items-in-chart: 200
+          summary-always: true
+          comment-on-alert: false
+          fail-on-alert: false
diff --git a/e2e/bundle-size/.gitignore b/e2e/bundle-size/.gitignore
new file mode 100644
index 0000000000..dc7703a1be
--- /dev/null
+++ b/e2e/bundle-size/.gitignore
@@ -0,0 +1,8 @@
+node_modules
+dist
+scenarios/*/src/routeTree.gen.ts
+
+results/*.json
+results/*.md
+results/*.js
+!results/.gitkeep
diff --git a/e2e/bundle-size/README.md b/e2e/bundle-size/README.md
new file mode 100644
index 0000000000..0f8ea0267d
--- /dev/null
+++ b/e2e/bundle-size/README.md
@@ -0,0 +1,58 @@
+# Bundle Size Benchmarks
+
+This workspace contains deterministic bundle-size fixtures for:
+
+- `@tanstack/react-router`
+- `@tanstack/solid-router`
+- `@tanstack/vue-router`
+- `@tanstack/react-start`
+- `@tanstack/solid-start`
+
+Each package has two scenarios:
+
+- `minimal`: Small route app with `__root` + index route that renders `hello world`
+- `full`: Same route shape plus a broad root-level harness that imports/uses the full hooks/components surface
+- Start `full` scenarios also exercise `createServerFn`, `createMiddleware`, and `useServerFn`
+
+## Design Notes
+
+- Scenarios use file-based routing as the default app style.
+- Router scenarios use `@tanstack/router-plugin/vite` with `autoCodeSplitting: true`.
+- Start scenarios use `@tanstack/<framework>-start/plugin/vite` with router code-splitting enabled.
+- Full-surface coverage is manually maintained (no strict export-coverage gate).
+- Metrics are measured from initial-load JS graph only and reported as raw/gzip/brotli bytes.
+- Gzip is the primary tracking signal for PR deltas and historical charting.
+
+## Local Run
+
+```bash
+pnpm nx run tanstack-router-e2e-bundle-size:build
+```
+
+This writes:
+
+- `e2e/bundle-size/results/current.json`
+- `e2e/bundle-size/results/benchmark-action.json`
+
+## CI Reporting
+
+- PR workflow generates a sticky comment with:
+  - current gzip values
+  - baseline delta
+  - inline sparkline trend
+- Pushes to `main` publish historical chart data to GitHub Pages via `benchmark-action/github-action-benchmark`.
+
+## Manual Update Policy
+
+When router/start public hooks/components evolve, update the corresponding `*-full/src/routes/__root.tsx` harness to keep full scenarios representative.
+
+## Backfill Readiness
+
+The measurement script supports optional interfaces for historical backfilling:
+
+- `--sha`
+- `--measured-at`
+- `--append-history`
+
+These are intended for one-off scripts that replay historical commits and append results to the same history dataset shape used for chart generation.
+If `--append-history` points at a `data.js` file, output is written as `window.BENCHMARK_DATA = ...` for direct GitHub Pages compatibility.
diff --git a/e2e/bundle-size/package.json b/e2e/bundle-size/package.json
new file mode 100644
index 0000000000..46975664c7
--- /dev/null
+++ b/e2e/bundle-size/package.json
@@ -0,0 +1,30 @@
+{
+  "name": "tanstack-router-e2e-bundle-size",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "build": "node ../../scripts/benchmarks/bundle-size/measure.mjs"
+  },
+  "dependencies": {
+    "@tanstack/react-router": "workspace:^",
+    "@tanstack/solid-router": "workspace:^",
+    "@tanstack/vue-router": "workspace:^",
+    "@tanstack/react-start": "workspace:^",
+    "@tanstack/solid-start": "workspace:^",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "solid-js": "^1.9.10",
+    "vue": "^3.5.25"
+  },
+  "devDependencies": {
+    "@tanstack/router-plugin": "workspace:^",
+    "@types/react": "^19.0.8",
+    "@types/react-dom": "^19.0.3",
+    "@vitejs/plugin-react": "^4.3.4",
+    "@vitejs/plugin-vue": "^6.0.1",
+    "@vitejs/plugin-vue-jsx": "^4.1.2",
+    "typescript": "^5.9.3",
+    "vite": "^7.3.1",
+    "vite-plugin-solid": "^2.11.10"
+  }
+}
diff --git a/e2e/bundle-size/results/.gitkeep b/e2e/bundle-size/results/.gitkeep
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/e2e/bundle-size/results/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/e2e/bundle-size/scenarios/react-router-full/index.html b/e2e/bundle-size/scenarios/react-router-full/index.html
new file mode 100644
index 0000000000..1117e75467
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-full/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>react-router-full</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/e2e/bundle-size/scenarios/react-router-full/src/main.tsx b/e2e/bundle-size/scenarios/react-router-full/src/main.tsx
new file mode 100644
index 0000000000..ce55e38d84
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-full/src/main.tsx
@@ -0,0 +1,22 @@
+import ReactDOM from 'react-dom/client'
+import { RouterProvider, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({
+  routeTree,
+  scrollRestoration: true,
+})
+
+declare module '@tanstack/react-router' {
+  interface Register {
+    router: typeof router
+  }
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Root element `#app` not found')
+}
+if (!rootElement.innerHTML) {
+  ReactDOM.createRoot(rootElement).render(<RouterProvider router={router} />)
+}
diff --git a/e2e/bundle-size/scenarios/react-router-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-router-full/src/routes/__root.tsx
new file mode 100644
index 0000000000..4c5e443555
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-full/src/routes/__root.tsx
@@ -0,0 +1,192 @@
+import {
+  Asset,
+  Await,
+  Block,
+  CatchBoundary,
+  CatchNotFound,
+  ClientOnly,
+  DefaultGlobalNotFound,
+  ErrorComponent,
+  HeadContent,
+  Link,
+  Match,
+  MatchRoute,
+  Matches,
+  Navigate,
+  Outlet,
+  RouterContextProvider,
+  ScriptOnce,
+  Scripts,
+  ScrollRestoration,
+  createLink,
+  createRootRoute,
+  linkOptions,
+  useAwaited,
+  useBlocker,
+  useCanGoBack,
+  useElementScrollRestoration,
+  useHydrated,
+  useLinkProps,
+  useLoaderData,
+  useLoaderDeps,
+  useLayoutEffect,
+  useLocation,
+  useMatch,
+  useMatchRoute,
+  useMatches,
+  useNavigate,
+  useParams,
+  useParentMatches,
+  useChildMatches,
+  useRouteContext,
+  useRouter,
+  useRouterState,
+  useSearch,
+  useStableCallback,
+  useTags,
+} from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  const router = useRouter()
+  const hydrated = useHydrated()
+  const awaited = useAwaited({ promise: Promise.resolve('ready') })
+  const linkProps = useLinkProps({ to: '/' } as any)
+  const matchRoute = useMatchRoute()
+  const matches = useMatches()
+  const parentMatches = useParentMatches()
+  const childMatches = useChildMatches()
+  const match = useMatch({ strict: false, shouldThrow: false } as any)
+  const loaderDeps = useLoaderDeps({ strict: false } as any)
+  const loaderData = useLoaderData({ strict: false } as any)
+  const params = useParams({ strict: false } as any)
+  const search = useSearch({ strict: false } as any)
+  const routeContext = useRouteContext({ strict: false } as any)
+  const routerState = useRouterState({ select: (state) => state.status } as any)
+  const location = useLocation()
+  const canGoBack = useCanGoBack()
+  const navigate = useNavigate()
+  const stableCallback = useStableCallback(() => {})
+  const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
+  const tags = useTags()
+
+  useLayoutEffect(() => {}, [])
+  useBlocker({
+    shouldBlockFn: () => false,
+    disabled: true,
+    withResolver: false,
+  })
+
+  const linkFactoryResult = linkOptions({ to: '/' } as any)
+  const routeMatchResult = matchRoute({ to: '/' } as any)
+  const SvgLink = createLink('svg')
+
+  const hooksAndComponents = [
+    useAwaited,
+    useHydrated,
+    useLinkProps,
+    useMatchRoute,
+    useMatches,
+    useParentMatches,
+    useChildMatches,
+    useMatch,
+    useLoaderDeps,
+    useLoaderData,
+    useLayoutEffect,
+    useBlocker,
+    useNavigate,
+    useParams,
+    useSearch,
+    useRouteContext,
+    useRouter,
+    useRouterState,
+    useLocation,
+    useCanGoBack,
+    useStableCallback,
+    useElementScrollRestoration,
+    useTags,
+    Await,
+    CatchBoundary,
+    CatchNotFound,
+    ClientOnly,
+    DefaultGlobalNotFound,
+    ErrorComponent,
+    Link,
+    Match,
+    MatchRoute,
+    Matches,
+    Navigate,
+    Outlet,
+    RouterContextProvider,
+    ScrollRestoration,
+    Block,
+    ScriptOnce,
+    Asset,
+    HeadContent,
+    Scripts,
+  ]
+
+  ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = {
+    hooksAndComponents,
+  }
+
+  void awaited
+  void linkFactoryResult
+  void matches
+  void parentMatches
+  void childMatches
+  void match
+  void loaderDeps
+  void loaderData
+  void params
+  void search
+  void routeContext
+  void routerState
+  void location
+  void canGoBack
+  stableCallback()
+  void navigate
+  void scrollEntry
+  void tags
+  void routeMatchResult
+
+  return (
+    <>
+      <HeadContent />
+      <ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
+      <Asset
+        tag="meta"
+        attrs={{ name: 'bundle-size', content: 'react-router-full' }}
+      />
+      <Link {...(linkProps as any)}>home</Link>
+      <SvgLink to="/" aria-label="svg-home">
+        <circle cx="8" cy="8" r="7" />
+      </SvgLink>
+      <MatchRoute to="/">{() => <span data-test="match-route" />}</MatchRoute>
+      <ClientOnly fallback={<span data-test="client-only-fallback" />}>
+        <span data-test="client-only" />
+      </ClientOnly>
+      <Await promise={Promise.resolve('done')}>
+        {() => <span data-test="await" />}
+      </Await>
+      <Block shouldBlockFn={() => false} disabled withResolver={false}>
+        {() => <span data-test="block" />}
+      </Block>
+      <CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
+        <span data-test="catch-not-found" />
+      </CatchNotFound>
+      <RouterContextProvider router={router}>
+        <span data-test="nested-router-context" />
+      </RouterContextProvider>
+      <ScrollRestoration />
+      <Outlet />
+      <Scripts />
+      <div data-test="full-root" data-hydrated={String(hydrated)}>
+        <div>hello world</div>
+      </div>
+    </>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/react-router-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-router-full/src/routes/index.tsx
new file mode 100644
index 0000000000..76fb363538
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-full/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/react-router-full/vite.config.ts b/e2e/bundle-size/scenarios/react-router-full/vite.config.ts
new file mode 100644
index 0000000000..45838fe281
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-full/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+  plugins: [
+    tanstackRouter({
+      target: 'react',
+      autoCodeSplitting: true,
+    }),
+    react(),
+  ],
+})
diff --git a/e2e/bundle-size/scenarios/react-router-minimal/index.html b/e2e/bundle-size/scenarios/react-router-minimal/index.html
new file mode 100644
index 0000000000..5de4e4e509
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-minimal/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>react-router-minimal</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/e2e/bundle-size/scenarios/react-router-minimal/src/main.tsx b/e2e/bundle-size/scenarios/react-router-minimal/src/main.tsx
new file mode 100644
index 0000000000..667de2cdc7
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-minimal/src/main.tsx
@@ -0,0 +1,19 @@
+import ReactDOM from 'react-dom/client'
+import { RouterProvider, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({ routeTree })
+
+declare module '@tanstack/react-router' {
+  interface Register {
+    router: typeof router
+  }
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Root element `#app` not found')
+}
+if (!rootElement.innerHTML) {
+  ReactDOM.createRoot(rootElement).render(<RouterProvider router={router} />)
+}
diff --git a/e2e/bundle-size/scenarios/react-router-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/__root.tsx
new file mode 100644
index 0000000000..889395056b
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/__root.tsx
@@ -0,0 +1,9 @@
+import { Outlet, createRootRoute } from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  return <Outlet />
+}
diff --git a/e2e/bundle-size/scenarios/react-router-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/index.tsx
new file mode 100644
index 0000000000..76fb363538
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/react-router-minimal/vite.config.ts b/e2e/bundle-size/scenarios/react-router-minimal/vite.config.ts
new file mode 100644
index 0000000000..45838fe281
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-router-minimal/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+  plugins: [
+    tanstackRouter({
+      target: 'react',
+      autoCodeSplitting: true,
+    }),
+    react(),
+  ],
+})
diff --git a/e2e/bundle-size/scenarios/react-start-full/src/router.tsx b/e2e/bundle-size/scenarios/react-start-full/src/router.tsx
new file mode 100644
index 0000000000..9d87d8748b
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-full/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+  return createRouter({
+    routeTree,
+    scrollRestoration: true,
+  })
+}
diff --git a/e2e/bundle-size/scenarios/react-start-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-start-full/src/routes/__root.tsx
new file mode 100644
index 0000000000..292383253c
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-full/src/routes/__root.tsx
@@ -0,0 +1,223 @@
+import {
+  Asset,
+  Await,
+  Block,
+  CatchBoundary,
+  CatchNotFound,
+  ClientOnly,
+  DefaultGlobalNotFound,
+  ErrorComponent,
+  HeadContent,
+  Link,
+  Match,
+  MatchRoute,
+  Matches,
+  Navigate,
+  Outlet,
+  RouterContextProvider,
+  ScriptOnce,
+  Scripts,
+  ScrollRestoration,
+  createLink,
+  createRootRoute,
+  linkOptions,
+  useAwaited,
+  useBlocker,
+  useCanGoBack,
+  useElementScrollRestoration,
+  useHydrated,
+  useLinkProps,
+  useLoaderData,
+  useLoaderDeps,
+  useLayoutEffect,
+  useLocation,
+  useMatch,
+  useMatchRoute,
+  useMatches,
+  useNavigate,
+  useParams,
+  useParentMatches,
+  useChildMatches,
+  useRouteContext,
+  useRouter,
+  useRouterState,
+  useSearch,
+  useStableCallback,
+  useTags,
+} from '@tanstack/react-router'
+import {
+  createMiddleware,
+  createServerFn,
+  useServerFn,
+} from '@tanstack/react-start'
+
+const requestMiddleware = createMiddleware().server(async ({ next }) => {
+  return next()
+})
+
+const functionMiddleware = createMiddleware({ type: 'function' })
+  .client(async ({ next }) => {
+    return next()
+  })
+  .server(async ({ next }) => {
+    return next()
+  })
+
+const helloServerFn = createServerFn({ method: 'GET' })
+  .middleware([requestMiddleware, functionMiddleware])
+  .handler(async () => {
+    return 'hello from server fn'
+  })
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  const router = useRouter()
+  const hydrated = useHydrated()
+  const awaited = useAwaited({ promise: Promise.resolve('ready') })
+  const linkProps = useLinkProps({ to: '/' } as any)
+  const matchRoute = useMatchRoute()
+  const matches = useMatches()
+  const parentMatches = useParentMatches()
+  const childMatches = useChildMatches()
+  const match = useMatch({ strict: false, shouldThrow: false } as any)
+  const loaderDeps = useLoaderDeps({ strict: false } as any)
+  const loaderData = useLoaderData({ strict: false } as any)
+  const params = useParams({ strict: false } as any)
+  const search = useSearch({ strict: false } as any)
+  const routeContext = useRouteContext({ strict: false } as any)
+  const routerState = useRouterState({ select: (state) => state.status } as any)
+  const location = useLocation()
+  const canGoBack = useCanGoBack()
+  const navigate = useNavigate()
+  const stableCallback = useStableCallback(() => {})
+  const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
+  const tags = useTags()
+  const invokeServerFn = useServerFn(helloServerFn)
+
+  useLayoutEffect(() => {}, [])
+  useBlocker({
+    shouldBlockFn: () => false,
+    disabled: true,
+    withResolver: false,
+  })
+
+  const linkFactoryResult = linkOptions({ to: '/' } as any)
+  const routeMatchResult = matchRoute({ to: '/' } as any)
+  const SvgLink = createLink('svg')
+
+  const startSurface = [createMiddleware, createServerFn, useServerFn]
+  const hooksAndComponents = [
+    useAwaited,
+    useHydrated,
+    useLinkProps,
+    useMatchRoute,
+    useMatches,
+    useParentMatches,
+    useChildMatches,
+    useMatch,
+    useLoaderDeps,
+    useLoaderData,
+    useLayoutEffect,
+    useBlocker,
+    useNavigate,
+    useParams,
+    useSearch,
+    useRouteContext,
+    useRouter,
+    useRouterState,
+    useLocation,
+    useCanGoBack,
+    useStableCallback,
+    useElementScrollRestoration,
+    useTags,
+    Await,
+    CatchBoundary,
+    CatchNotFound,
+    ClientOnly,
+    DefaultGlobalNotFound,
+    ErrorComponent,
+    Link,
+    Match,
+    MatchRoute,
+    Matches,
+    Navigate,
+    Outlet,
+    RouterContextProvider,
+    ScrollRestoration,
+    Block,
+    ScriptOnce,
+    Asset,
+    HeadContent,
+    Scripts,
+  ]
+
+  ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = {
+    hooksAndComponents,
+    startSurface,
+  }
+
+  void awaited
+  void linkFactoryResult
+  void matches
+  void parentMatches
+  void childMatches
+  void match
+  void loaderDeps
+  void loaderData
+  void params
+  void search
+  void routeContext
+  void routerState
+  void location
+  void canGoBack
+  stableCallback()
+  void navigate
+  void scrollEntry
+  void tags
+  void routeMatchResult
+  void invokeServerFn
+
+  return (
+    <html>
+      <head>
+        <HeadContent />
+      </head>
+      <body>
+        <ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
+        <Asset
+          tag="meta"
+          attrs={{ name: 'bundle-size', content: 'react-start-full' }}
+        />
+        <Link {...(linkProps as any)}>home</Link>
+        <SvgLink to="/" aria-label="svg-home">
+          <circle cx="8" cy="8" r="7" />
+        </SvgLink>
+        <MatchRoute to="/">{() => <span data-test="match-route" />}</MatchRoute>
+        <ClientOnly fallback={<span data-test="client-only-fallback" />}>
+          <span data-test="client-only" />
+        </ClientOnly>
+        <Await promise={Promise.resolve('done')}>
+          {() => <span data-test="await" />}
+        </Await>
+        <Block shouldBlockFn={() => false} disabled withResolver={false}>
+          {() => <span data-test="block" />}
+        </Block>
+        <CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
+          <span data-test="catch-not-found" />
+        </CatchNotFound>
+        <RouterContextProvider router={router}>
+          <span data-test="nested-router-context" />
+        </RouterContextProvider>
+        <ScrollRestoration />
+        <Outlet />
+        <Scripts />
+        <div data-test="full-root" data-hydrated={String(hydrated)}>
+          <div>hello world</div>
+        </div>
+      </body>
+    </html>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/react-start-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-start-full/src/routes/index.tsx
new file mode 100644
index 0000000000..76fb363538
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-full/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/react-start-full/vite.config.ts b/e2e/bundle-size/scenarios/react-start-full/vite.config.ts
new file mode 100644
index 0000000000..d4e4cd980d
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-full/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import viteReact from '@vitejs/plugin-react'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+
+export default defineConfig({
+  plugins: [tanstackStart(), viteReact()],
+})
diff --git a/e2e/bundle-size/scenarios/react-start-minimal/src/router.tsx b/e2e/bundle-size/scenarios/react-start-minimal/src/router.tsx
new file mode 100644
index 0000000000..9d87d8748b
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-minimal/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+  return createRouter({
+    routeTree,
+    scrollRestoration: true,
+  })
+}
diff --git a/e2e/bundle-size/scenarios/react-start-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/__root.tsx
new file mode 100644
index 0000000000..ff1da4c304
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/__root.tsx
@@ -0,0 +1,24 @@
+import {
+  HeadContent,
+  Outlet,
+  Scripts,
+  createRootRoute,
+} from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  return (
+    <html>
+      <head>
+        <HeadContent />
+      </head>
+      <body>
+        <Outlet />
+        <Scripts />
+      </body>
+    </html>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/react-start-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/index.tsx
new file mode 100644
index 0000000000..76fb363538
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/react-start-minimal/vite.config.ts b/e2e/bundle-size/scenarios/react-start-minimal/vite.config.ts
new file mode 100644
index 0000000000..d4e4cd980d
--- /dev/null
+++ b/e2e/bundle-size/scenarios/react-start-minimal/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import viteReact from '@vitejs/plugin-react'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+
+export default defineConfig({
+  plugins: [tanstackStart(), viteReact()],
+})
diff --git a/e2e/bundle-size/scenarios/solid-router-full/index.html b/e2e/bundle-size/scenarios/solid-router-full/index.html
new file mode 100644
index 0000000000..80ef4474ee
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-full/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>solid-router-full</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/e2e/bundle-size/scenarios/solid-router-full/src/main.tsx b/e2e/bundle-size/scenarios/solid-router-full/src/main.tsx
new file mode 100644
index 0000000000..505f79aee0
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-full/src/main.tsx
@@ -0,0 +1,22 @@
+import { render } from 'solid-js/web'
+import { RouterProvider, createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({
+  routeTree,
+  scrollRestoration: true,
+})
+
+declare module '@tanstack/solid-router' {
+  interface Register {
+    router: typeof router
+  }
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Root element `#app` not found')
+}
+if (!rootElement.innerHTML) {
+  render(() => <RouterProvider router={router} />, rootElement)
+}
diff --git a/e2e/bundle-size/scenarios/solid-router-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-router-full/src/routes/__root.tsx
new file mode 100644
index 0000000000..3b0f7435cb
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-full/src/routes/__root.tsx
@@ -0,0 +1,188 @@
+import {
+  Asset,
+  Await,
+  Block,
+  CatchBoundary,
+  CatchNotFound,
+  ClientOnly,
+  DefaultGlobalNotFound,
+  ErrorComponent,
+  HeadContent,
+  Link,
+  Match,
+  MatchRoute,
+  Matches,
+  Navigate,
+  Outlet,
+  RouterContextProvider,
+  ScriptOnce,
+  Scripts,
+  ScrollRestoration,
+  createLink,
+  createRootRoute,
+  linkOptions,
+  useAwaited,
+  useBlocker,
+  useCanGoBack,
+  useChildMatches,
+  useElementScrollRestoration,
+  useHydrated,
+  useLayoutEffect,
+  useLinkProps,
+  useLoaderData,
+  useLoaderDeps,
+  useLocation,
+  useMatch,
+  useMatchRoute,
+  useMatches,
+  useNavigate,
+  useParams,
+  useParentMatches,
+  useRouteContext,
+  useRouter,
+  useRouterState,
+  useSearch,
+  useTags,
+} from '@tanstack/solid-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  const router = useRouter()
+  const hydrated = useHydrated()
+  const [awaited] = useAwaited({ promise: Promise.resolve('ready') })
+  const linkProps = useLinkProps({ to: '/' } as any)
+  const matchRoute = useMatchRoute()
+  const matches = useMatches()
+  const parentMatches = useParentMatches()
+  const childMatches = useChildMatches()
+  const match = useMatch({ strict: false, shouldThrow: false } as any)
+  const loaderDeps = useLoaderDeps({ strict: false } as any)
+  const loaderData = useLoaderData({ strict: false } as any)
+  const params = useParams({ strict: false } as any)
+  const search = useSearch({ strict: false } as any)
+  const routeContext = useRouteContext({ strict: false } as any)
+  const routerState = useRouterState({ select: (state) => state.status } as any)
+  const location = useLocation()
+  const canGoBack = useCanGoBack()
+  const navigate = useNavigate()
+  const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
+  const tags = useTags()
+
+  useLayoutEffect(() => {})
+  useBlocker({
+    shouldBlockFn: () => false,
+    disabled: true,
+    withResolver: false,
+  })
+
+  const linkFactoryResult = linkOptions({ to: '/' } as any)
+  const routeMatchResult = matchRoute({ to: '/' } as any)
+  const SvgLink = createLink('svg')
+
+  const hooksAndComponents = [
+    useAwaited,
+    useHydrated,
+    useLinkProps,
+    useMatchRoute,
+    useMatches,
+    useParentMatches,
+    useChildMatches,
+    useMatch,
+    useLoaderDeps,
+    useLoaderData,
+    useLayoutEffect,
+    useBlocker,
+    useNavigate,
+    useParams,
+    useSearch,
+    useRouteContext,
+    useRouter,
+    useRouterState,
+    useLocation,
+    useCanGoBack,
+    useElementScrollRestoration,
+    useTags,
+    Await,
+    CatchBoundary,
+    CatchNotFound,
+    ClientOnly,
+    DefaultGlobalNotFound,
+    ErrorComponent,
+    Link,
+    Match,
+    MatchRoute,
+    Matches,
+    Navigate,
+    Outlet,
+    RouterContextProvider,
+    ScrollRestoration,
+    Block,
+    ScriptOnce,
+    Asset,
+    HeadContent,
+    Scripts,
+  ]
+
+  ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = {
+    hooksAndComponents,
+  }
+
+  void awaited
+  void linkFactoryResult
+  void matches()
+  void parentMatches()
+  void childMatches()
+  void match()
+  void loaderDeps()
+  void loaderData()
+  void params()
+  void search()
+  void routeContext()
+  void routerState()
+  void location()
+  void canGoBack()
+  void navigate
+  void scrollEntry
+  void tags()
+  void routeMatchResult()
+
+  return (
+    <>
+      <HeadContent />
+      <ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
+      <Asset
+        tag="meta"
+        attrs={{ name: 'bundle-size', content: 'solid-router-full' }}
+      />
+      <Link {...(linkProps as any)}>home</Link>
+      <SvgLink to="/" aria-label="svg-home">
+        <circle cx="8" cy="8" r="7" />
+      </SvgLink>
+      <MatchRoute to="/">{() => <span data-test="match-route" />}</MatchRoute>
+      <ClientOnly fallback={<span data-test="client-only-fallback" />}>
+        <span data-test="client-only" />
+      </ClientOnly>
+      <Await promise={Promise.resolve('done')}>
+        {() => <span data-test="await" />}
+      </Await>
+      <Block shouldBlockFn={() => false} disabled withResolver={false}>
+        {() => <span data-test="block" />}
+      </Block>
+      <CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
+        <span data-test="catch-not-found" />
+      </CatchNotFound>
+      <RouterContextProvider router={router}>
+        {() => <span data-test="nested-router-context" />}
+      </RouterContextProvider>
+      <ScrollRestoration />
+      <Outlet />
+      <Scripts />
+      <div data-test="full-root" data-hydrated={String(hydrated())}>
+        <div>hello world</div>
+      </div>
+    </>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/solid-router-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-router-full/src/routes/index.tsx
new file mode 100644
index 0000000000..01d3b701ae
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-full/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/solid-router-full/vite.config.ts b/e2e/bundle-size/scenarios/solid-router-full/vite.config.ts
new file mode 100644
index 0000000000..9729ada0e8
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-full/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import solid from 'vite-plugin-solid'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+  plugins: [
+    tanstackRouter({
+      target: 'solid',
+      autoCodeSplitting: true,
+    }),
+    solid(),
+  ],
+})
diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/index.html b/e2e/bundle-size/scenarios/solid-router-minimal/index.html
new file mode 100644
index 0000000000..29c6fbd787
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-minimal/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>solid-router-minimal</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/src/main.tsx b/e2e/bundle-size/scenarios/solid-router-minimal/src/main.tsx
new file mode 100644
index 0000000000..210120b3f1
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-minimal/src/main.tsx
@@ -0,0 +1,19 @@
+import { render } from 'solid-js/web'
+import { RouterProvider, createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({ routeTree })
+
+declare module '@tanstack/solid-router' {
+  interface Register {
+    router: typeof router
+  }
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Root element `#app` not found')
+}
+if (!rootElement.innerHTML) {
+  render(() => <RouterProvider router={router} />, rootElement)
+}
diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/__root.tsx
new file mode 100644
index 0000000000..cb8d5a688d
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/__root.tsx
@@ -0,0 +1,9 @@
+import { Outlet, createRootRoute } from '@tanstack/solid-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  return <Outlet />
+}
diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/index.tsx
new file mode 100644
index 0000000000..01d3b701ae
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/vite.config.ts b/e2e/bundle-size/scenarios/solid-router-minimal/vite.config.ts
new file mode 100644
index 0000000000..9729ada0e8
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-router-minimal/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import solid from 'vite-plugin-solid'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+  plugins: [
+    tanstackRouter({
+      target: 'solid',
+      autoCodeSplitting: true,
+    }),
+    solid(),
+  ],
+})
diff --git a/e2e/bundle-size/scenarios/solid-start-full/src/router.tsx b/e2e/bundle-size/scenarios/solid-start-full/src/router.tsx
new file mode 100644
index 0000000000..aa7ead6752
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-full/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+  return createRouter({
+    routeTree,
+    scrollRestoration: true,
+  })
+}
diff --git a/e2e/bundle-size/scenarios/solid-start-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-start-full/src/routes/__root.tsx
new file mode 100644
index 0000000000..6a986000ed
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-full/src/routes/__root.tsx
@@ -0,0 +1,228 @@
+import {
+  Asset,
+  Await,
+  Block,
+  CatchBoundary,
+  CatchNotFound,
+  ClientOnly,
+  DefaultGlobalNotFound,
+  ErrorComponent,
+  HeadContent,
+  Link,
+  Match,
+  MatchRoute,
+  Matches,
+  Navigate,
+  Outlet,
+  RouterContextProvider,
+  ScriptOnce,
+  Scripts,
+  ScrollRestoration,
+  createLink,
+  createRootRoute,
+  linkOptions,
+  useAwaited,
+  useBlocker,
+  useCanGoBack,
+  useChildMatches,
+  useElementScrollRestoration,
+  useHydrated,
+  useLayoutEffect,
+  useLinkProps,
+  useLoaderData,
+  useLoaderDeps,
+  useLocation,
+  useMatch,
+  useMatchRoute,
+  useMatches,
+  useNavigate,
+  useParams,
+  useParentMatches,
+  useRouteContext,
+  useRouter,
+  useRouterState,
+  useSearch,
+  useTags,
+} from '@tanstack/solid-router'
+import {
+  createMiddleware,
+  createServerFn,
+  useServerFn,
+} from '@tanstack/solid-start'
+
+type BundleSizeKeep = {
+  hooksAndComponents: ReadonlyArray<unknown>
+  startSurface: ReadonlyArray<unknown>
+}
+
+declare global {
+  var __TANSTACK_BUNDLE_SIZE_KEEP__: BundleSizeKeep | undefined
+}
+
+const requestMiddleware = createMiddleware().server(async ({ next }) => {
+  return next()
+})
+
+const functionMiddleware = createMiddleware({ type: 'function' })
+  .client(async ({ next }) => {
+    return next()
+  })
+  .server(async ({ next }) => {
+    return next()
+  })
+
+const helloServerFn = createServerFn({ method: 'GET' })
+  .middleware([requestMiddleware, functionMiddleware])
+  .handler(async () => {
+    return 'hello from server fn'
+  })
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  const router = useRouter()
+  const hydrated = useHydrated()
+  const [awaited] = useAwaited({ promise: Promise.resolve('ready') })
+  const linkProps = useLinkProps({ to: '/' })
+  const matchRoute = useMatchRoute()
+  const matches = useMatches()
+  const parentMatches = useParentMatches()
+  const childMatches = useChildMatches()
+  const match = useMatch({ strict: false, shouldThrow: false })
+  const loaderDeps = useLoaderDeps({ strict: false })
+  const loaderData = useLoaderData({ strict: false })
+  const params = useParams({ strict: false })
+  const search = useSearch({ strict: false })
+  const routeContext = useRouteContext({ strict: false })
+  const routerState = useRouterState({ select: (state) => state.status })
+  const location = useLocation()
+  const canGoBack = useCanGoBack()
+  const navigate = useNavigate()
+  const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
+  const tags = useTags()
+  const invokeServerFn = useServerFn(helloServerFn)
+
+  useLayoutEffect(() => {})
+  useBlocker({
+    shouldBlockFn: () => false,
+    disabled: true,
+    withResolver: false,
+  })
+
+  const linkFactoryResult = linkOptions({ to: '/' })
+  const routeMatchResult = matchRoute({ to: '/' })
+  const SvgLink = createLink('svg')
+
+  const startSurface = [createMiddleware, createServerFn, useServerFn]
+  const hooksAndComponents = [
+    useAwaited,
+    useHydrated,
+    useLinkProps,
+    useMatchRoute,
+    useMatches,
+    useParentMatches,
+    useChildMatches,
+    useMatch,
+    useLoaderDeps,
+    useLoaderData,
+    useLayoutEffect,
+    useBlocker,
+    useNavigate,
+    useParams,
+    useSearch,
+    useRouteContext,
+    useRouter,
+    useRouterState,
+    useLocation,
+    useCanGoBack,
+    useElementScrollRestoration,
+    useTags,
+    Await,
+    CatchBoundary,
+    CatchNotFound,
+    ClientOnly,
+    DefaultGlobalNotFound,
+    ErrorComponent,
+    Link,
+    Match,
+    MatchRoute,
+    Matches,
+    Navigate,
+    Outlet,
+    RouterContextProvider,
+    ScrollRestoration,
+    Block,
+    ScriptOnce,
+    Asset,
+    HeadContent,
+    Scripts,
+  ]
+
+  globalThis.__TANSTACK_BUNDLE_SIZE_KEEP__ = {
+    hooksAndComponents,
+    startSurface,
+  }
+
+  void awaited
+  void linkFactoryResult
+  void matches()
+  void parentMatches()
+  void childMatches()
+  void match()
+  void loaderDeps()
+  void loaderData()
+  void params()
+  void search()
+  void routeContext()
+  void routerState()
+  void location()
+  void canGoBack()
+  void navigate
+  void scrollEntry
+  void tags()
+  void routeMatchResult()
+  void invokeServerFn
+
+  return (
+    <html>
+      <head>
+        <HeadContent />
+      </head>
+      <body>
+        <ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
+        <Asset
+          tag="meta"
+          attrs={{ name: 'bundle-size', content: 'solid-start-full' }}
+        />
+        <Link {...linkProps}>home</Link>
+        <SvgLink to="/" aria-label="svg-home">
+          <circle cx="8" cy="8" r="7" />
+        </SvgLink>
+        <MatchRoute to="/">{() => <span data-test="match-route" />}</MatchRoute>
+        <ClientOnly fallback={<span data-test="client-only-fallback" />}>
+          <span data-test="client-only" />
+        </ClientOnly>
+        <Await promise={Promise.resolve('done')}>
+          {() => <span data-test="await" />}
+        </Await>
+        <Block shouldBlockFn={() => false} disabled withResolver={false}>
+          {() => <span data-test="block" />}
+        </Block>
+        <CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
+          <span data-test="catch-not-found" />
+        </CatchNotFound>
+        <RouterContextProvider router={router}>
+          {() => <span data-test="nested-router-context" />}
+        </RouterContextProvider>
+        <ScrollRestoration />
+        <Outlet />
+        <Scripts />
+        <div data-test="full-root" data-hydrated={String(hydrated())}>
+          <div>hello world</div>
+        </div>
+      </body>
+    </html>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/solid-start-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-start-full/src/routes/index.tsx
new file mode 100644
index 0000000000..01d3b701ae
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-full/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/solid-start-full/vite.config.ts b/e2e/bundle-size/scenarios/solid-start-full/vite.config.ts
new file mode 100644
index 0000000000..0bd21e64f4
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-full/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import solid from 'vite-plugin-solid'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+
+export default defineConfig({
+  plugins: [tanstackStart(), solid({ ssr: true })],
+})
diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/src/router.tsx b/e2e/bundle-size/scenarios/solid-start-minimal/src/router.tsx
new file mode 100644
index 0000000000..aa7ead6752
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-minimal/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+  return createRouter({
+    routeTree,
+    scrollRestoration: true,
+  })
+}
diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/__root.tsx
new file mode 100644
index 0000000000..e59de72236
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/__root.tsx
@@ -0,0 +1,24 @@
+import {
+  HeadContent,
+  Outlet,
+  Scripts,
+  createRootRoute,
+} from '@tanstack/solid-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  return (
+    <html>
+      <head>
+        <HeadContent />
+      </head>
+      <body>
+        <Outlet />
+        <Scripts />
+      </body>
+    </html>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/index.tsx
new file mode 100644
index 0000000000..01d3b701ae
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/vite.config.ts b/e2e/bundle-size/scenarios/solid-start-minimal/vite.config.ts
new file mode 100644
index 0000000000..0bd21e64f4
--- /dev/null
+++ b/e2e/bundle-size/scenarios/solid-start-minimal/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import solid from 'vite-plugin-solid'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+
+export default defineConfig({
+  plugins: [tanstackStart(), solid({ ssr: true })],
+})
diff --git a/e2e/bundle-size/scenarios/vue-router-full/index.html b/e2e/bundle-size/scenarios/vue-router-full/index.html
new file mode 100644
index 0000000000..583379e36c
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-full/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>vue-router-full</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/e2e/bundle-size/scenarios/vue-router-full/src/main.tsx b/e2e/bundle-size/scenarios/vue-router-full/src/main.tsx
new file mode 100644
index 0000000000..84d5f29df5
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-full/src/main.tsx
@@ -0,0 +1,26 @@
+import { createApp } from 'vue'
+import { RouterProvider, createRouter } from '@tanstack/vue-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({
+  routeTree,
+  scrollRestoration: true,
+})
+
+declare module '@tanstack/vue-router' {
+  interface Register {
+    router: typeof router
+  }
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Root element `#app` not found')
+}
+if (!rootElement.innerHTML) {
+  createApp({
+    setup() {
+      return () => <RouterProvider router={router} />
+    },
+  }).mount('#app')
+}
diff --git a/e2e/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx
new file mode 100644
index 0000000000..77e81f6bbb
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx
@@ -0,0 +1,191 @@
+import {
+  Asset,
+  Await,
+  Block,
+  Body,
+  CatchBoundary,
+  CatchNotFound,
+  ClientOnly,
+  DefaultGlobalNotFound,
+  ErrorComponent,
+  HeadContent,
+  Html,
+  Link,
+  Match,
+  MatchRoute,
+  Matches,
+  Navigate,
+  Outlet,
+  RouterContextProvider,
+  ScriptOnce,
+  Scripts,
+  ScrollRestoration,
+  createLink,
+  createRootRoute,
+  linkOptions,
+  useAwaited,
+  useBlocker,
+  useCanGoBack,
+  useChildMatches,
+  useElementScrollRestoration,
+  useLayoutEffect,
+  useLinkProps,
+  useLoaderData,
+  useLoaderDeps,
+  useLocation,
+  useMatch,
+  useMatchRoute,
+  useMatches,
+  useNavigate,
+  useParams,
+  useParentMatches,
+  useRouteContext,
+  useRouter,
+  useRouterState,
+  useSearch,
+  useTags,
+} from '@tanstack/vue-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  const router = useRouter()
+  const [awaited] = useAwaited({ promise: Promise.resolve('ready') })
+  const linkProps = useLinkProps({ to: '/' } as any)
+  const matchRoute = useMatchRoute()
+  const matches = useMatches()
+  const parentMatches = useParentMatches()
+  const childMatches = useChildMatches()
+  const match = useMatch({ strict: false, shouldThrow: false } as any)
+  const loaderDeps = useLoaderDeps({ strict: false } as any)
+  const loaderData = useLoaderData({ strict: false } as any)
+  const params = useParams({ strict: false } as any)
+  const search = useSearch({ strict: false } as any)
+  const routeContext = useRouteContext({ strict: false } as any)
+  const routerState = useRouterState({ select: (state) => state.status } as any)
+  const location = useLocation()
+  const canGoBack = useCanGoBack()
+  const navigate = useNavigate()
+  const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
+  const tags = useTags()
+  const layoutEffectRunner = useLayoutEffect(() => {})
+
+  useBlocker({
+    shouldBlockFn: () => false,
+    disabled: true,
+    withResolver: false,
+  })
+
+  const linkFactoryResult = linkOptions({ to: '/' } as any)
+  const routeMatchResult = matchRoute({ to: '/' } as any)
+  const SvgLink = createLink('svg')
+
+  const hooksAndComponents = [
+    useAwaited,
+    useLinkProps,
+    useMatchRoute,
+    useMatches,
+    useParentMatches,
+    useChildMatches,
+    useMatch,
+    useLoaderDeps,
+    useLoaderData,
+    useLayoutEffect,
+    useBlocker,
+    useNavigate,
+    useParams,
+    useSearch,
+    useRouteContext,
+    useRouter,
+    useRouterState,
+    useLocation,
+    useCanGoBack,
+    useElementScrollRestoration,
+    useTags,
+    Await,
+    CatchBoundary,
+    CatchNotFound,
+    ClientOnly,
+    DefaultGlobalNotFound,
+    ErrorComponent,
+    Link,
+    Match,
+    MatchRoute,
+    Matches,
+    Navigate,
+    Outlet,
+    RouterContextProvider,
+    ScrollRestoration,
+    Block,
+    ScriptOnce,
+    Asset,
+    HeadContent,
+    Scripts,
+    Body,
+    Html,
+  ]
+
+  ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = {
+    hooksAndComponents,
+  }
+
+  void awaited
+  void linkFactoryResult
+  void matches.value
+  void parentMatches.value
+  void childMatches.value
+  void match.value
+  void loaderDeps.value
+  void loaderData.value
+  void params.value
+  void search.value
+  void routeContext.value
+  void routerState.value
+  void location.value
+  void canGoBack.value
+  void navigate
+  void scrollEntry
+  void tags()
+  void layoutEffectRunner
+  void routeMatchResult.value
+
+  return (
+    <>
+      <HeadContent />
+      <ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
+      <Asset
+        tag="meta"
+        attrs={{ name: 'bundle-size', content: 'vue-router-full' }}
+      />
+      <Link {...(linkProps as any)}>home</Link>
+      <SvgLink to="/" aria-label="svg-home">
+        <circle cx="8" cy="8" r="7" />
+      </SvgLink>
+      <MatchRoute to="/">{() => <span data-test="match-route" />}</MatchRoute>
+      <ClientOnly fallback={<span data-test="client-only-fallback" />}>
+        <span data-test="client-only" />
+      </ClientOnly>
+      <Await
+        promise={Promise.resolve('done')}
+        children={() => <span data-test="await" />}
+      />
+      <Block shouldBlockFn={() => false} disabled withResolver={false}>
+        {() => <span data-test="block" />}
+      </Block>
+      <CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
+        <span data-test="catch-not-found" />
+      </CatchNotFound>
+      <RouterContextProvider router={router}>
+        <span data-test="nested-router-context" />
+      </RouterContextProvider>
+      <ScrollRestoration />
+      <Outlet />
+      <Scripts />
+      <div data-test="full-root">
+        <div>hello world</div>
+      </div>
+    </>
+  )
+}
diff --git a/e2e/bundle-size/scenarios/vue-router-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/vue-router-full/src/routes/index.tsx
new file mode 100644
index 0000000000..3899022bdd
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-full/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/vue-router-full/vite.config.ts b/e2e/bundle-size/scenarios/vue-router-full/vite.config.ts
new file mode 100644
index 0000000000..5556bfaa07
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-full/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+  plugins: [
+    tanstackRouter({
+      target: 'vue',
+      autoCodeSplitting: true,
+    }),
+    vue(),
+    vueJsx(),
+  ],
+})
diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/index.html b/e2e/bundle-size/scenarios/vue-router-minimal/index.html
new file mode 100644
index 0000000000..e3fe534a21
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-minimal/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>vue-router-minimal</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/src/main.tsx b/e2e/bundle-size/scenarios/vue-router-minimal/src/main.tsx
new file mode 100644
index 0000000000..1fc66c27b1
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-minimal/src/main.tsx
@@ -0,0 +1,23 @@
+import { createApp } from 'vue'
+import { RouterProvider, createRouter } from '@tanstack/vue-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({ routeTree })
+
+declare module '@tanstack/vue-router' {
+  interface Register {
+    router: typeof router
+  }
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+  throw new Error('Root element `#app` not found')
+}
+if (!rootElement.innerHTML) {
+  createApp({
+    setup() {
+      return () => <RouterProvider router={router} />
+    },
+  }).mount('#app')
+}
diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/__root.tsx
new file mode 100644
index 0000000000..91296e6f84
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/__root.tsx
@@ -0,0 +1,9 @@
+import { Outlet, createRootRoute } from '@tanstack/vue-router'
+
+export const Route = createRootRoute({
+  component: RootComponent,
+})
+
+function RootComponent() {
+  return <Outlet />
+}
diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/index.tsx
new file mode 100644
index 0000000000..3899022bdd
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/')({
+  component: IndexComponent,
+})
+
+function IndexComponent() {
+  return <div>hello world</div>
+}
diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/vite.config.ts b/e2e/bundle-size/scenarios/vue-router-minimal/vite.config.ts
new file mode 100644
index 0000000000..5556bfaa07
--- /dev/null
+++ b/e2e/bundle-size/scenarios/vue-router-minimal/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+  plugins: [
+    tanstackRouter({
+      target: 'vue',
+      autoCodeSplitting: true,
+    }),
+    vue(),
+    vueJsx(),
+  ],
+})
diff --git a/e2e/bundle-size/tsconfig.json b/e2e/bundle-size/tsconfig.json
new file mode 100644
index 0000000000..ec9f5c598c
--- /dev/null
+++ b/e2e/bundle-size/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "strict": true,
+    "esModuleInterop": true,
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "jsx": "preserve",
+    "allowJs": true,
+    "skipLibCheck": true,
+    "types": ["vite/client"]
+  },
+  "exclude": ["node_modules", "dist"]
+}
diff --git a/package.json b/package.json
index 15e059571c..5b6c19258f 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
     "test:build": "nx affected --target=test:build --exclude=examples/**",
     "test:types": "nx affected --target=test:types --exclude=examples/**",
     "test:e2e": "nx run-many --target=test:e2e",
+    "benchmark:bundle-size": "pnpm nx run tanstack-router-e2e-bundle-size:build",
     "build": "nx affected --target=build --exclude=e2e/** --exclude=examples/**",
     "build:all": "nx run-many --target=build --exclude=examples/** --exclude=e2e/**",
     "watch": "pnpm run build:all && nx watch --all -- pnpm run build:all",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a3afb217f4..1ce263e6a1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -158,6 +158,64 @@ importers:
         specifier: ^4.0.17
         version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.7.0(@types/node@25.0.9)(typescript@5.9.3))(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
 
+  e2e/bundle-size:
+    dependencies:
+      '@tanstack/react-router':
+        specifier: workspace:*
+        version: link:../../packages/react-router
+      '@tanstack/react-start':
+        specifier: workspace:*
+        version: link:../../packages/react-start
+      '@tanstack/solid-router':
+        specifier: workspace:^
+        version: link:../../packages/solid-router
+      '@tanstack/solid-start':
+        specifier: workspace:*
+        version: link:../../packages/solid-start
+      '@tanstack/vue-router':
+        specifier: workspace:*
+        version: link:../../packages/vue-router
+      react:
+        specifier: ^19.2.3
+        version: 19.2.3
+      react-dom:
+        specifier: ^19.2.3
+        version: 19.2.3(react@19.2.3)
+      solid-js:
+        specifier: 1.9.10
+        version: 1.9.10
+      vue:
+        specifier: ^3.5.25
+        version: 3.5.25(typescript@5.9.3)
+    devDependencies:
+      '@tanstack/router-plugin':
+        specifier: workspace:*
+        version: link:../../packages/router-plugin
+      '@types/react':
+        specifier: ^19.2.8
+        version: 19.2.8
+      '@types/react-dom':
+        specifier: ^19.2.3
+        version: 19.2.3(@types/react@19.2.8)
+      '@vitejs/plugin-react':
+        specifier: ^4.3.4
+        version: 4.7.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+      '@vitejs/plugin-vue':
+        specifier: ^6.0.1
+        version: 6.0.3(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3))
+      '@vitejs/plugin-vue-jsx':
+        specifier: ^4.1.2
+        version: 4.2.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3))
+      typescript:
+        specifier: ^5.9.3
+        version: 5.9.3
+      vite:
+        specifier: ^7.3.1
+        version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+      vite-plugin-solid:
+        specifier: ^2.11.10
+        version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.10)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+
   e2e/e2e-utils:
     devDependencies:
       get-port-please:
@@ -25830,7 +25888,7 @@ snapshots:
       '@emotion/memoize': 0.9.0
       '@emotion/unitless': 0.10.0
       '@emotion/utils': 1.4.2
-      csstype: 3.1.3
+      csstype: 3.2.3
 
   '@emotion/sheet@1.4.0': {}
 
@@ -27382,7 +27440,7 @@ snapshots:
       '@emotion/cache': 11.14.0
       '@emotion/serialize': 1.3.3
       '@emotion/sheet': 1.4.0
-      csstype: 3.1.3
+      csstype: 3.2.3
       prop-types: 15.8.1
       react: 19.2.3
     optionalDependencies:
@@ -27397,7 +27455,7 @@ snapshots:
       '@mui/types': 7.2.21(@types/react@19.2.8)
       '@mui/utils': 6.4.6(@types/react@19.2.8)(react@19.2.3)
       clsx: 2.1.1
-      csstype: 3.1.3
+      csstype: 3.2.3
       prop-types: 15.8.1
       react: 19.2.3
     optionalDependencies:
@@ -31111,6 +31169,12 @@ snapshots:
       vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
       vue: 3.5.25(typescript@5.9.2)
 
+  '@vitejs/plugin-vue@6.0.3(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3))':
+    dependencies:
+      '@rolldown/pluginutils': 1.0.0-beta.53
+      vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+      vue: 3.5.25(typescript@5.9.3)
+
   '@vitest/browser@4.0.17(msw@2.7.0(@types/node@25.0.9)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.17)':
     dependencies:
       '@vitest/mocker': 4.0.17(msw@2.7.0(@types/node@25.0.9)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
@@ -32911,7 +32975,7 @@ snapshots:
   dom-helpers@5.2.1:
     dependencies:
       '@babel/runtime': 7.26.7
-      csstype: 3.1.3
+      csstype: 3.2.3
 
   dom-serializer@1.4.1:
     dependencies:
@@ -37385,7 +37449,7 @@ snapshots:
 
   solid-js@1.9.10:
     dependencies:
-      csstype: 3.1.3
+      csstype: 3.2.3
       seroval: 1.3.2
       seroval-plugins: 1.3.2(seroval@1.3.2)
 
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index da7d07317b..fe84aeb037 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -11,6 +11,7 @@ packages:
   - 'examples/react/router-monorepo-simple/packages/*'
   - 'examples/react/router-monorepo-simple-lazy/packages/*'
   - 'e2e/e2e-utils'
+  - 'e2e/bundle-size'
   - 'e2e/react-router/*'
   - 'e2e/solid-router/*'
   - 'e2e/vue-router/*'
diff --git a/scripts/benchmarks/bundle-size/measure.mjs b/scripts/benchmarks/bundle-size/measure.mjs
new file mode 100644
index 0000000000..00b76c9f2d
--- /dev/null
+++ b/scripts/benchmarks/bundle-size/measure.mjs
@@ -0,0 +1,516 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs'
+import { promises as fsp } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { parseArgs as parseNodeArgs } from 'node:util'
+import { brotliCompressSync, gzipSync } from 'node:zlib'
+import { execSync } from 'node:child_process'
+
+import { build } from 'vite'
+
+const BENCHMARK_NAME = 'Bundle Size (gzip)'
+
+const SCENARIOS = [
+  {
+    id: 'react-router.minimal',
+    dir: 'react-router-minimal',
+    framework: 'react',
+    packageName: '@tanstack/react-router',
+    case: 'minimal',
+  },
+  {
+    id: 'react-router.full',
+    dir: 'react-router-full',
+    framework: 'react',
+    packageName: '@tanstack/react-router',
+    case: 'full',
+  },
+  {
+    id: 'solid-router.minimal',
+    dir: 'solid-router-minimal',
+    framework: 'solid',
+    packageName: '@tanstack/solid-router',
+    case: 'minimal',
+  },
+  {
+    id: 'solid-router.full',
+    dir: 'solid-router-full',
+    framework: 'solid',
+    packageName: '@tanstack/solid-router',
+    case: 'full',
+  },
+  {
+    id: 'vue-router.minimal',
+    dir: 'vue-router-minimal',
+    framework: 'vue',
+    packageName: '@tanstack/vue-router',
+    case: 'minimal',
+  },
+  {
+    id: 'vue-router.full',
+    dir: 'vue-router-full',
+    framework: 'vue',
+    packageName: '@tanstack/vue-router',
+    case: 'full',
+  },
+  {
+    id: 'react-start.minimal',
+    dir: 'react-start-minimal',
+    framework: 'react',
+    packageName: '@tanstack/react-start',
+    case: 'minimal',
+  },
+  {
+    id: 'react-start.full',
+    dir: 'react-start-full',
+    framework: 'react',
+    packageName: '@tanstack/react-start',
+    case: 'full',
+  },
+  {
+    id: 'solid-start.minimal',
+    dir: 'solid-start-minimal',
+    framework: 'solid',
+    packageName: '@tanstack/solid-start',
+    case: 'minimal',
+  },
+  {
+    id: 'solid-start.full',
+    dir: 'solid-start-full',
+    framework: 'solid',
+    packageName: '@tanstack/solid-start',
+    case: 'full',
+  },
+]
+
+function parseArgs(argv) {
+  const { values } = parseNodeArgs({
+    args: argv,
+    allowPositionals: false,
+    strict: true,
+    options: {
+      sha: { type: 'string' },
+      'measured-at': { type: 'string' },
+      'append-history': { type: 'string' },
+      'results-dir': { type: 'string' },
+      'dist-dir': { type: 'string' },
+    },
+  })
+
+  return {
+    sha: values.sha,
+    measuredAt: values['measured-at'],
+    appendHistory: values['append-history'],
+    resultsDir: values['results-dir'],
+    distDir: values['dist-dir'],
+  }
+}
+
+function toIsoDate(value) {
+  const date = new Date(value)
+
+  if (Number.isNaN(date.getTime())) {
+    throw new Error(`Invalid date: ${value}`)
+  }
+
+  return date.toISOString()
+}
+
+function readJson(filePath) {
+  return JSON.parse(fs.readFileSync(filePath, 'utf8'))
+}
+
+function parseMaybeDataJs(raw) {
+  const trimmed = raw.trim()
+
+  if (trimmed.startsWith('window.BENCHMARK_DATA')) {
+    const withoutPrefix = trimmed
+      .replace(/^window\.BENCHMARK_DATA\s*=\s*/, '')
+      .replace(/;\s*$/, '')
+    return JSON.parse(withoutPrefix)
+  }
+
+  return JSON.parse(trimmed)
+}
+
+function resolveManifestChunkKey(manifest, keyOrFile) {
+  if (manifest[keyOrFile]) {
+    return keyOrFile
+  }
+
+  for (const [key, value] of Object.entries(manifest)) {
+    if (value?.file === keyOrFile) {
+      return key
+    }
+  }
+
+  return undefined
+}
+
+function collectInitialJsFiles(manifest, entryKey) {
+  const visitedKeys = new Set()
+  const files = new Set()
+
+  function visitByKey(chunkKey) {
+    if (!chunkKey || visitedKeys.has(chunkKey)) {
+      return
+    }
+
+    visitedKeys.add(chunkKey)
+    const chunk = manifest[chunkKey]
+
+    if (!chunk) {
+      return
+    }
+
+    if (typeof chunk.file === 'string' && chunk.file.endsWith('.js')) {
+      files.add(chunk.file)
+    }
+
+    for (const imported of chunk.imports || []) {
+      const resolvedKey = resolveManifestChunkKey(manifest, imported)
+      if (resolvedKey) {
+        visitByKey(resolvedKey)
+      }
+    }
+  }
+
+  visitByKey(entryKey)
+
+  return [...files].sort()
+}
+
+function bytesForFiles(baseDir, fileList) {
+  let rawBytes = 0
+  let gzipBytes = 0
+  let brotliBytes = 0
+
+  for (const relativeFile of fileList) {
+    const fullPath = path.join(baseDir, relativeFile)
+    const content = fs.readFileSync(fullPath)
+
+    rawBytes += content.byteLength
+    gzipBytes += gzipSync(content).byteLength
+    brotliBytes += brotliCompressSync(content).byteLength
+  }
+
+  return {
+    rawBytes,
+    gzipBytes,
+    brotliBytes,
+  }
+}
+
+async function findManifestFiles(rootDir) {
+  const manifestPaths = []
+
+  async function visit(currentDir) {
+    const entries = await fsp.readdir(currentDir, { withFileTypes: true })
+
+    for (const entry of entries) {
+      const fullPath = path.join(currentDir, entry.name)
+
+      if (entry.isDirectory()) {
+        await visit(fullPath)
+        continue
+      }
+
+      if (entry.isFile() && entry.name === 'manifest.json') {
+        const parent = path.basename(path.dirname(fullPath))
+        if (parent === '.vite') {
+          manifestPaths.push(fullPath)
+        }
+      }
+    }
+  }
+
+  await visit(rootDir)
+  return manifestPaths
+}
+
+function rankManifestPath(manifestPath) {
+  let score = 0
+
+  if (manifestPath.includes(`${path.sep}client${path.sep}`)) {
+    score += 100
+  }
+
+  if (manifestPath.includes(`${path.sep}server${path.sep}`)) {
+    score -= 100
+  }
+
+  return score
+}
+
+function pickManifestEntryKey(manifest) {
+  const entries = Object.entries(manifest)
+
+  const htmlEntry = entries.find(
+    ([key, value]) => key.endsWith('.html') && value?.isEntry,
+  )
+  if (htmlEntry) {
+    return htmlEntry[0]
+  }
+
+  const jsEntries = entries.filter(
+    ([, value]) =>
+      value?.isEntry &&
+      typeof value.file === 'string' &&
+      value.file.endsWith('.js'),
+  )
+
+  const preferredPatterns = [
+    /virtual:tanstack-start-client-entry/i,
+    /src[\\/]main\./i,
+    /src[\\/]client\./i,
+    /client/i,
+  ]
+
+  for (const pattern of preferredPatterns) {
+    const preferred = jsEntries.find(
+      ([key, value]) => pattern.test(key) || pattern.test(value.file),
+    )
+
+    if (preferred) {
+      return preferred[0]
+    }
+  }
+
+  if (jsEntries.length > 0) {
+    return jsEntries[0][0]
+  }
+
+  const anyEntry = entries.find(([, value]) => value?.isEntry)
+  if (anyEntry) {
+    return anyEntry[0]
+  }
+
+  return undefined
+}
+
+async function resolveManifestAndEntry(outDir, scenarioId) {
+  const manifestPaths = await findManifestFiles(outDir)
+
+  if (manifestPaths.length === 0) {
+    throw new Error(`No Vite manifest files found for scenario: ${scenarioId}`)
+  }
+
+  manifestPaths.sort((a, b) => {
+    const scoreDiff = rankManifestPath(b) - rankManifestPath(a)
+    if (scoreDiff !== 0) {
+      return scoreDiff
+    }
+
+    return a.length - b.length
+  })
+
+  for (const manifestPath of manifestPaths) {
+    const manifest = readJson(manifestPath)
+    const entryKey = pickManifestEntryKey(manifest)
+
+    if (!entryKey) {
+      continue
+    }
+
+    const manifestOutDir = path.dirname(path.dirname(manifestPath))
+
+    return {
+      manifest,
+      entryKey,
+      manifestPath,
+      manifestOutDir,
+    }
+  }
+
+  throw new Error(
+    `Could not determine manifest entry for scenario: ${scenarioId}`,
+  )
+}
+
+function getCurrentSha(providedSha) {
+  if (providedSha) {
+    return providedSha
+  }
+
+  if (process.env.GITHUB_SHA) {
+    return process.env.GITHUB_SHA
+  }
+
+  return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim()
+}
+
+function buildCommitUrl(sha) {
+  const repo = process.env.GITHUB_REPOSITORY
+
+  if (!repo) {
+    return ''
+  }
+
+  return `https://github.com/${repo}/commit/${sha}`
+}
+
+async function appendHistoryFile({ historyPath, measuredAtIso, sha, benches }) {
+  const measuredAtMs = Date.parse(measuredAtIso)
+  let writeAsDataJs = historyPath.endsWith('.js')
+
+  let history = {
+    lastUpdate: measuredAtMs,
+    entries: {},
+  }
+
+  if (fs.existsSync(historyPath)) {
+    const raw = await fsp.readFile(historyPath, 'utf8')
+    writeAsDataJs = raw.trim().startsWith('window.BENCHMARK_DATA')
+    history = parseMaybeDataJs(raw)
+    history.entries ||= {}
+  }
+
+  const entry = {
+    commit: {
+      id: sha,
+      message: `bundle-size snapshot ${sha.slice(0, 12)}`,
+      timestamp: measuredAtIso,
+      url: buildCommitUrl(sha),
+    },
+    date: measuredAtMs,
+    tool: 'customSmallerIsBetter',
+    benches,
+  }
+
+  const group = history.entries[BENCHMARK_NAME] || []
+  group.push(entry)
+  history.entries[BENCHMARK_NAME] = group
+  history.lastUpdate = measuredAtMs
+
+  await fsp.mkdir(path.dirname(historyPath), { recursive: true })
+  const serialized = JSON.stringify(history, null, 2)
+  const output = writeAsDataJs
+    ? `window.BENCHMARK_DATA = ${serialized};\n`
+    : `${serialized}\n`
+  await fsp.writeFile(historyPath, output, 'utf8')
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2))
+
+  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
+  const repoRoot = path.resolve(scriptDir, '../../../')
+  const scenariosRoot = path.join(repoRoot, 'e2e/bundle-size/scenarios')
+  const resultsDir = args.resultsDir
+    ? path.resolve(args.resultsDir)
+    : path.join(repoRoot, 'e2e/bundle-size/results')
+  const distDir = args.distDir
+    ? path.resolve(args.distDir)
+    : path.join(repoRoot, 'e2e/bundle-size/dist')
+
+  const measuredAtIso = args.measuredAt
+    ? toIsoDate(args.measuredAt)
+    : new Date().toISOString()
+  const sha = getCurrentSha(args.sha)
+
+  await fsp.mkdir(resultsDir, { recursive: true })
+  await fsp.mkdir(distDir, { recursive: true })
+
+  const metrics = []
+
+  for (const scenario of SCENARIOS) {
+    const root = path.join(scenariosRoot, scenario.dir)
+    const outDir = path.join(distDir, scenario.dir)
+    const configFile = path.join(root, 'vite.config.ts')
+
+    const previousCwd = process.cwd()
+    process.chdir(root)
+
+    try {
+      await build({
+        root,
+        configFile,
+        logLevel: 'silent',
+        define: {
+          'process.env.NODE_ENV': '"production"',
+        },
+        build: {
+          outDir,
+          emptyOutDir: true,
+          target: 'es2022',
+          minify: 'esbuild',
+          sourcemap: false,
+          reportCompressedSize: false,
+          manifest: true,
+        },
+      })
+    } finally {
+      process.chdir(previousCwd)
+    }
+
+    const manifestInfo = await resolveManifestAndEntry(outDir, scenario.id)
+
+    const jsFiles = collectInitialJsFiles(
+      manifestInfo.manifest,
+      manifestInfo.entryKey,
+    )
+    const sizes = bytesForFiles(manifestInfo.manifestOutDir, jsFiles)
+
+    metrics.push({
+      id: scenario.id,
+      scenarioDir: scenario.dir,
+      framework: scenario.framework,
+      packageName: scenario.packageName,
+      case: scenario.case,
+      entryKey: manifestInfo.entryKey,
+      manifestPath: path.relative(outDir, manifestInfo.manifestPath),
+      jsFiles,
+      ...sizes,
+    })
+  }
+
+  const current = {
+    schemaVersion: 1,
+    benchmarkName: BENCHMARK_NAME,
+    measuredAt: measuredAtIso,
+    generatedAt: new Date().toISOString(),
+    sha,
+    metrics,
+  }
+
+  const benchmarkActionRows = metrics.map((metric) => ({
+    name: metric.id,
+    unit: 'bytes',
+    value: metric.gzipBytes,
+    extra: `raw=${metric.rawBytes}; brotli=${metric.brotliBytes}`,
+  }))
+
+  const currentPath = path.join(resultsDir, 'current.json')
+  const benchmarkActionPath = path.join(resultsDir, 'benchmark-action.json')
+
+  await fsp.writeFile(
+    currentPath,
+    JSON.stringify(current, null, 2) + '\n',
+    'utf8',
+  )
+  await fsp.writeFile(
+    benchmarkActionPath,
+    JSON.stringify(benchmarkActionRows, null, 2) + '\n',
+    'utf8',
+  )
+
+  if (args.appendHistory) {
+    await appendHistoryFile({
+      historyPath: path.resolve(args.appendHistory),
+      measuredAtIso,
+      sha,
+      benches: benchmarkActionRows,
+    })
+  }
+
+  process.stdout.write(
+    `Measured ${metrics.length} scenarios. Wrote ${path.relative(repoRoot, currentPath)} and ${path.relative(repoRoot, benchmarkActionPath)}\n`,
+  )
+}
+
+main().catch((error) => {
+  console.error(error)
+  process.exit(1)
+})
diff --git a/scripts/benchmarks/bundle-size/pr-report.mjs b/scripts/benchmarks/bundle-size/pr-report.mjs
new file mode 100644
index 0000000000..ea7585815e
--- /dev/null
+++ b/scripts/benchmarks/bundle-size/pr-report.mjs
@@ -0,0 +1,337 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs'
+import { promises as fsp } from 'node:fs'
+import path from 'node:path'
+import { parseArgs as parseNodeArgs } from 'node:util'
+
+const DEFAULT_MARKER = '<!-- bundle-size-benchmark -->'
+const INT_FORMAT = new Intl.NumberFormat('en-US', {
+  maximumFractionDigits: 0,
+})
+const FIXED_2_FORMAT = new Intl.NumberFormat('en-US', {
+  minimumFractionDigits: 2,
+  maximumFractionDigits: 2,
+})
+const PERCENT_FORMAT = new Intl.NumberFormat('en-US', {
+  style: 'percent',
+  minimumFractionDigits: 2,
+  maximumFractionDigits: 2,
+})
+
+function parseArgs(argv) {
+  const { values } = parseNodeArgs({
+    args: argv,
+    allowPositionals: false,
+    strict: true,
+    options: {
+      current: { type: 'string' },
+      baseline: { type: 'string' },
+      history: { type: 'string' },
+      output: { type: 'string' },
+      'dashboard-url': { type: 'string' },
+      'base-sha': { type: 'string' },
+      marker: { type: 'string' },
+      'trend-points': { type: 'string' },
+    },
+  })
+
+  const args = {
+    current: values.current,
+    baseline: values.baseline,
+    history: values.history,
+    output: values.output,
+    dashboardUrl: values['dashboard-url'],
+    baseSha: values['base-sha'],
+    marker: values.marker ?? DEFAULT_MARKER,
+    trendPoints: values['trend-points']
+      ? Number.parseInt(values['trend-points'], 10)
+      : 12,
+  }
+
+  if (!Number.isFinite(args.trendPoints) || args.trendPoints < 2) {
+    throw new Error(`Invalid trend points: ${values['trend-points']}`)
+  }
+
+  if (!args.current) {
+    throw new Error('Missing required argument: --current')
+  }
+
+  if (!args.output) {
+    throw new Error('Missing required argument: --output')
+  }
+
+  return args
+}
+
+function parseMaybeDataJs(raw) {
+  const trimmed = raw.trim()
+
+  if (trimmed.startsWith('window.BENCHMARK_DATA')) {
+    return JSON.parse(
+      trimmed
+        .replace(/^window\.BENCHMARK_DATA\s*=\s*/, '')
+        .replace(/;\s*$/, ''),
+    )
+  }
+
+  return JSON.parse(trimmed)
+}
+
+function readJsonMaybeData(filePath) {
+  return parseMaybeDataJs(fs.readFileSync(filePath, 'utf8'))
+}
+
+function readOptionalJsonMaybeData(filePath) {
+  if (!filePath || !fs.existsSync(filePath)) {
+    return undefined
+  }
+
+  const raw = fs.readFileSync(filePath, 'utf8')
+  if (!raw.trim()) {
+    return undefined
+  }
+
+  return parseMaybeDataJs(raw)
+}
+
+function formatBytes(bytes, opts = {}) {
+  const signed = opts.signed === true
+
+  if (!Number.isFinite(bytes)) {
+    return 'n/a'
+  }
+
+  const sign = signed && bytes !== 0 ? (bytes > 0 ? '+' : '-') : ''
+  const absBytes = Math.abs(bytes)
+
+  let value
+  if (absBytes < 1024) {
+    value = `${INT_FORMAT.format(absBytes)} B`
+  } else {
+    const kib = absBytes / 1024
+    if (kib < 1024) {
+      value = `${FIXED_2_FORMAT.format(kib)} KiB`
+    } else {
+      const mib = kib / 1024
+      value = `${FIXED_2_FORMAT.format(mib)} MiB`
+    }
+  }
+
+  return `${sign}${value}`
+}
+
+function formatDelta(current, baseline) {
+  if (!Number.isFinite(current) || !Number.isFinite(baseline)) {
+    return 'n/a'
+  }
+
+  const delta = current - baseline
+  const ratio = baseline === 0 ? 0 : Math.abs(delta / baseline)
+  const sign = delta > 0 ? '+' : delta < 0 ? '-' : ''
+  return `${formatBytes(delta, { signed: true })} (${sign}${PERCENT_FORMAT.format(ratio)})`
+}
+
+function sparkline(values) {
+  if (!values.length) {
+    return 'n/a'
+  }
+
+  const blocks = '▁▂▃▄▅▆▇█'
+  const min = Math.min(...values)
+  const max = Math.max(...values)
+
+  if (max === min) {
+    return '▅'.repeat(values.length)
+  }
+
+  return values
+    .map((value) => {
+      const normalized = (value - min) / (max - min)
+      const idx = Math.min(
+        blocks.length - 1,
+        Math.max(0, Math.round(normalized * (blocks.length - 1))),
+      )
+      return blocks[idx]
+    })
+    .join('')
+}
+
+function normalizeHistoryEntries(history, benchmarkName) {
+  if (!history || typeof history !== 'object' || !history.entries) {
+    return []
+  }
+
+  const byName = history.entries[benchmarkName]
+  if (Array.isArray(byName)) {
+    return byName
+  }
+
+  const firstEntry = Object.values(history.entries).find((value) =>
+    Array.isArray(value),
+  )
+  return Array.isArray(firstEntry) ? firstEntry : []
+}
+
+function buildSeriesByScenario(historyEntries) {
+  const map = new Map()
+
+  for (const entry of historyEntries) {
+    for (const bench of entry?.benches || []) {
+      if (typeof bench?.name !== 'string' || !Number.isFinite(bench?.value)) {
+        continue
+      }
+
+      if (!map.has(bench.name)) {
+        map.set(bench.name, [])
+      }
+
+      map.get(bench.name).push(Number(bench.value))
+    }
+  }
+
+  return map
+}
+
+function resolveBaselineFromHistory(historyEntries, baseSha) {
+  if (!historyEntries.length) {
+    return {
+      source: 'none',
+      benchesByName: new Map(),
+    }
+  }
+
+  const baseEntry =
+    (baseSha &&
+      historyEntries.find(
+        (entry) =>
+          entry?.commit?.id === baseSha ||
+          entry?.commit?.id?.startsWith(baseSha),
+      )) ||
+    historyEntries[historyEntries.length - 1]
+
+  const benchesByName = new Map()
+  for (const bench of baseEntry?.benches || []) {
+    if (typeof bench?.name === 'string' && Number.isFinite(bench?.value)) {
+      benchesByName.set(bench.name, Number(bench.value))
+    }
+  }
+
+  const commitId = baseEntry?.commit?.id || 'unknown'
+
+  return {
+    source: `history:${commitId.slice(0, 12)}`,
+    benchesByName,
+  }
+}
+
+function resolveBaselineFromCurrentJson(currentJson) {
+  const benchesByName = new Map()
+  for (const metric of currentJson?.metrics || []) {
+    if (typeof metric?.id === 'string' && Number.isFinite(metric?.gzipBytes)) {
+      benchesByName.set(metric.id, Number(metric.gzipBytes))
+    }
+  }
+
+  const sourceSha =
+    typeof currentJson?.sha === 'string' ? currentJson.sha : 'unknown'
+
+  return {
+    source: `current:${sourceSha.slice(0, 12)}`,
+    benchesByName,
+  }
+}
+
+function formatShortSha(value) {
+  if (!value || typeof value !== 'string') {
+    return 'unknown'
+  }
+
+  return value.slice(0, 12)
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2))
+  const currentPath = path.resolve(args.current)
+  const outputPath = path.resolve(args.output)
+  const baselinePath = args.baseline ? path.resolve(args.baseline) : undefined
+  const historyPath = args.history ? path.resolve(args.history) : undefined
+
+  const current = readJsonMaybeData(currentPath)
+  const history = readOptionalJsonMaybeData(historyPath)
+  const baselineCurrent = readOptionalJsonMaybeData(baselinePath)
+
+  const historyEntries = normalizeHistoryEntries(history, current.benchmarkName)
+  const seriesByScenario = buildSeriesByScenario(historyEntries)
+
+  const baseline =
+    baselineCurrent != null
+      ? resolveBaselineFromCurrentJson(baselineCurrent)
+      : resolveBaselineFromHistory(historyEntries, args.baseSha)
+
+  const rows = []
+
+  for (const metric of current.metrics || []) {
+    const baselineValue = baseline.benchesByName.get(metric.id)
+    const historySeries = (seriesByScenario.get(metric.id) || []).slice(
+      // Reserve one slot for the current metric so the sparkline stays at trendPoints.
+      -args.trendPoints + 1,
+    )
+
+    if (
+      !historySeries.length ||
+      historySeries[historySeries.length - 1] !== metric.gzipBytes
+    ) {
+      historySeries.push(metric.gzipBytes)
+    }
+
+    rows.push({
+      id: metric.id,
+      current: metric.gzipBytes,
+      raw: metric.rawBytes,
+      brotli: metric.brotliBytes,
+      deltaCell: formatDelta(metric.gzipBytes, baselineValue),
+      trendCell: sparkline(historySeries.slice(-args.trendPoints)),
+    })
+  }
+
+  const lines = []
+  lines.push(args.marker)
+  lines.push('## Bundle Size Benchmarks')
+  lines.push('')
+  lines.push(`- Commit: \`${formatShortSha(current.sha)}\``)
+  lines.push(
+    `- Measured at: \`${current.measuredAt || current.generatedAt || 'unknown'}\``,
+  )
+  lines.push(`- Baseline source: \`${baseline.source}\``)
+  if (args.dashboardUrl) {
+    lines.push(`- Dashboard: [bundle-size history](${args.dashboardUrl})`)
+  }
+  lines.push('')
+  lines.push(
+    '| Scenario | Current (gzip) | Delta vs baseline | Raw | Brotli | Trend |',
+  )
+  lines.push('| --- | ---: | ---: | ---: | ---: | --- |')
+
+  for (const row of rows) {
+    lines.push(
+      `| \`${row.id}\` | ${formatBytes(row.current)} | ${row.deltaCell} | ${formatBytes(row.raw)} | ${formatBytes(row.brotli)} | ${row.trendCell} |`,
+    )
+  }
+
+  lines.push('')
+  lines.push(
+    '_Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better._',
+  )
+
+  const markdown = lines.join('\n') + '\n'
+  await fsp.mkdir(path.dirname(outputPath), { recursive: true })
+  await fsp.writeFile(outputPath, markdown, 'utf8')
+
+  process.stdout.write(`Wrote PR benchmark report: ${outputPath}\n`)
+}
+
+main().catch((error) => {
+  console.error(error)
+  process.exit(1)
+})
diff --git a/scripts/benchmarks/common/upsert-pr-comment.mjs b/scripts/benchmarks/common/upsert-pr-comment.mjs
new file mode 100644
index 0000000000..9637e21325
--- /dev/null
+++ b/scripts/benchmarks/common/upsert-pr-comment.mjs
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+
+import { promises as fsp } from 'node:fs'
+import path from 'node:path'
+import { parseArgs as parseNodeArgs } from 'node:util'
+
+const DEFAULT_MARKER = '<!-- bundle-size-benchmark -->'
+
+function parseArgs(argv) {
+  const { values } = parseNodeArgs({
+    args: argv,
+    allowPositionals: false,
+    strict: true,
+    options: {
+      pr: { type: 'string' },
+      'body-file': { type: 'string' },
+      repo: { type: 'string' },
+      token: { type: 'string' },
+      marker: { type: 'string' },
+      'api-url': { type: 'string' },
+    },
+  })
+
+  const args = {
+    pr: values.pr ? Number.parseInt(values.pr, 10) : undefined,
+    bodyFile: values['body-file'],
+    repo: values.repo ?? process.env.GITHUB_REPOSITORY,
+    marker: values.marker ?? DEFAULT_MARKER,
+    token: values.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN),
+    apiUrl:
+      values['api-url'] ??
+      (process.env.GITHUB_API_URL || 'https://api.github.com'),
+  }
+
+  if (!Number.isFinite(args.pr) || args.pr <= 0) {
+    throw new Error('Missing required argument: --pr')
+  }
+
+  if (!args.bodyFile) {
+    throw new Error('Missing required argument: --body-file')
+  }
+
+  if (!args.repo || !args.repo.includes('/')) {
+    throw new Error(
+      'Missing repository context. Provide --repo or GITHUB_REPOSITORY.',
+    )
+  }
+
+  if (!args.token) {
+    throw new Error('Missing token. Provide --token or GITHUB_TOKEN.')
+  }
+
+  return args
+}
+
+async function githubRequest({ apiUrl, token, method, endpoint, body }) {
+  const url = `${apiUrl.replace(/\/$/, '')}${endpoint}`
+  const response = await fetch(url, {
+    method,
+    headers: {
+      Authorization: `Bearer ${token}`,
+      Accept: 'application/vnd.github+json',
+      'User-Agent': 'tanstack-router-bundle-size-bot',
+      'Content-Type': 'application/json',
+    },
+    body: body ? JSON.stringify(body) : undefined,
+  })
+
+  if (!response.ok) {
+    const text = await response.text()
+    throw new Error(
+      `${method} ${endpoint} failed (${response.status} ${response.statusText}): ${text}`,
+    )
+  }
+
+  if (response.status === 204) {
+    return undefined
+  }
+
+  return response.json()
+}
+
+async function listIssueComments({ apiUrl, token, repo, pr }) {
+  const comments = []
+  let page = 1
+  const perPage = 100
+
+  for (;;) {
+    const data = await githubRequest({
+      apiUrl,
+      token,
+      method: 'GET',
+      endpoint: `/repos/${repo}/issues/${pr}/comments?per_page=${perPage}&page=${page}`,
+    })
+
+    comments.push(...data)
+
+    if (!Array.isArray(data) || data.length < perPage) {
+      break
+    }
+
+    page += 1
+  }
+
+  return comments
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2))
+  const bodyPath = path.resolve(args.bodyFile)
+  const rawBody = await fsp.readFile(bodyPath, 'utf8')
+  const body = rawBody.includes(args.marker)
+    ? rawBody
+    : `${args.marker}\n${rawBody}`
+
+  const comments = await listIssueComments({
+    apiUrl: args.apiUrl,
+    token: args.token,
+    repo: args.repo,
+    pr: args.pr,
+  })
+
+  const existing = comments.find(
+    (comment) =>
+      typeof comment?.body === 'string' && comment.body.includes(args.marker),
+  )
+
+  if (existing) {
+    await githubRequest({
+      apiUrl: args.apiUrl,
+      token: args.token,
+      method: 'PATCH',
+      endpoint: `/repos/${args.repo}/issues/comments/${existing.id}`,
+      body: { body },
+    })
+
+    process.stdout.write(
+      `Updated PR #${args.pr} bundle-size comment (${existing.id}).\n`,
+    )
+    return
+  }
+
+  const created = await githubRequest({
+    apiUrl: args.apiUrl,
+    token: args.token,
+    method: 'POST',
+    endpoint: `/repos/${args.repo}/issues/${args.pr}/comments`,
+    body: { body },
+  })
+
+  process.stdout.write(
+    `Created PR #${args.pr} bundle-size comment (${created?.id ?? 'unknown'}).\n`,
+  )
+}
+
+main().catch((error) => {
+  console.error(error)
+  process.exit(1)
+})
