diff --git a/e2e/vue-start/server-routes/.gitignore b/e2e/vue-start/server-routes/.gitignore
new file mode 100644
index 00000000000..a79d5cf1299
--- /dev/null
+++ b/e2e/vue-start/server-routes/.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/vue-start/server-routes/.prettierignore b/e2e/vue-start/server-routes/.prettierignore
new file mode 100644
index 00000000000..2be5eaa6ece
--- /dev/null
+++ b/e2e/vue-start/server-routes/.prettierignore
@@ -0,0 +1,4 @@
+**/build
+**/public
+pnpm-lock.yaml
+routeTree.gen.ts
\ No newline at end of file
diff --git a/e2e/vue-start/server-routes/package.json b/e2e/vue-start/server-routes/package.json
new file mode 100644
index 00000000000..31a1444bccb
--- /dev/null
+++ b/e2e/vue-start/server-routes/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "tanstack-vue-start-e2e-server-routes",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "dev:e2e": "vite dev",
+ "build": "vite build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "pnpx srvx --prod -s ../client dist/server/server.js",
+ "test:e2e": "playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/vue-query": "^5.90.9",
+ "@tanstack/vue-router": "workspace:^",
+ "@tanstack/vue-router-devtools": "workspace:^",
+ "@tanstack/vue-router-ssr-query": "workspace:^",
+ "@tanstack/vue-start": "workspace:^",
+ "js-cookie": "^3.0.5",
+ "redaxios": "^0.5.1",
+ "tailwind-merge": "^2.6.0",
+ "vite": "^7.1.7",
+ "vue": "^3.5.25",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.15",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/js-cookie": "^3.0.6",
+ "@types/node": "^22.10.2",
+ "combinate": "^1.1.11",
+ "postcss": "^8.5.1",
+ "srvx": "^0.8.6",
+ "tailwindcss": "^4.1.17",
+ "typescript": "^5.7.2",
+ "@vitejs/plugin-vue": "^6.0.3",
+ "@vitejs/plugin-vue-jsx": "^5.1.2",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/e2e/vue-start/server-routes/playwright.config.ts b/e2e/vue-start/server-routes/playwright.config.ts
new file mode 100644
index 00000000000..cb1da03b942
--- /dev/null
+++ b/e2e/vue-start/server-routes/playwright.config.ts
@@ -0,0 +1,35 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+export const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+
+ reporter: [['line']],
+
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL,
+ },
+
+ webServer: {
+ command: `pnpm build && 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/vue-start/server-routes/postcss.config.mjs b/e2e/vue-start/server-routes/postcss.config.mjs
new file mode 100644
index 00000000000..a7f73a2d1d7
--- /dev/null
+++ b/e2e/vue-start/server-routes/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
diff --git a/e2e/vue-start/server-routes/public/favicon.ico b/e2e/vue-start/server-routes/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/e2e/vue-start/server-routes/public/favicon.ico differ
diff --git a/e2e/vue-start/server-routes/public/favicon.png b/e2e/vue-start/server-routes/public/favicon.png
new file mode 100644
index 00000000000..1e77bc06091
Binary files /dev/null and b/e2e/vue-start/server-routes/public/favicon.png differ
diff --git a/e2e/vue-start/server-routes/src/components/DefaultCatchBoundary.tsx b/e2e/vue-start/server-routes/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000000..b1f818dd747
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from '@tanstack/vue-router'
+import type { ErrorComponentProps } from '@tanstack/vue-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+
+ {isRoot.value ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/components/NotFound.tsx b/e2e/vue-start/server-routes/src/components/NotFound.tsx
new file mode 100644
index 00000000000..944e35c12c6
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/vue-router'
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/routeTree.gen.ts b/e2e/vue-start/server-routes/src/routeTree.gen.ts
new file mode 100644
index 00000000000..7d9d463772e
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routeTree.gen.ts
@@ -0,0 +1,257 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as MergeMiddlewareContextRouteImport } from './routes/merge-middleware-context'
+import { Route as MethodsRouteRouteImport } from './routes/methods/route'
+import { Route as IndexRouteImport } from './routes/index'
+import { Route as MethodsIndexRouteImport } from './routes/methods/index'
+import { Route as MethodsOnlyAnyRouteImport } from './routes/methods/only-any'
+import { Route as ApiOnlyAnyRouteImport } from './routes/api/only-any'
+import { Route as ApiMiddlewareContextRouteImport } from './routes/api/middleware-context'
+import { Route as ApiParamsFooRouteRouteImport } from './routes/api/params/$foo/route'
+import { Route as ApiParamsFooBarRouteImport } from './routes/api/params/$foo/$bar'
+
+const MergeMiddlewareContextRoute = MergeMiddlewareContextRouteImport.update({
+ id: '/merge-middleware-context',
+ path: '/merge-middleware-context',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const MethodsRouteRoute = MethodsRouteRouteImport.update({
+ id: '/methods',
+ path: '/methods',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const MethodsIndexRoute = MethodsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => MethodsRouteRoute,
+} as any)
+const MethodsOnlyAnyRoute = MethodsOnlyAnyRouteImport.update({
+ id: '/only-any',
+ path: '/only-any',
+ getParentRoute: () => MethodsRouteRoute,
+} as any)
+const ApiOnlyAnyRoute = ApiOnlyAnyRouteImport.update({
+ id: '/api/only-any',
+ path: '/api/only-any',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({
+ id: '/api/middleware-context',
+ path: '/api/middleware-context',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiParamsFooRouteRoute = ApiParamsFooRouteRouteImport.update({
+ id: '/api/params/$foo',
+ path: '/api/params/$foo',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiParamsFooBarRoute = ApiParamsFooBarRouteImport.update({
+ id: '/$bar',
+ path: '/$bar',
+ getParentRoute: () => ApiParamsFooRouteRoute,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/methods': typeof MethodsRouteRouteWithChildren
+ '/merge-middleware-context': typeof MergeMiddlewareContextRoute
+ '/api/middleware-context': typeof ApiMiddlewareContextRoute
+ '/api/only-any': typeof ApiOnlyAnyRoute
+ '/methods/only-any': typeof MethodsOnlyAnyRoute
+ '/methods/': typeof MethodsIndexRoute
+ '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren
+ '/api/params/$foo/$bar': typeof ApiParamsFooBarRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/merge-middleware-context': typeof MergeMiddlewareContextRoute
+ '/api/middleware-context': typeof ApiMiddlewareContextRoute
+ '/api/only-any': typeof ApiOnlyAnyRoute
+ '/methods/only-any': typeof MethodsOnlyAnyRoute
+ '/methods': typeof MethodsIndexRoute
+ '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren
+ '/api/params/$foo/$bar': typeof ApiParamsFooBarRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/methods': typeof MethodsRouteRouteWithChildren
+ '/merge-middleware-context': typeof MergeMiddlewareContextRoute
+ '/api/middleware-context': typeof ApiMiddlewareContextRoute
+ '/api/only-any': typeof ApiOnlyAnyRoute
+ '/methods/only-any': typeof MethodsOnlyAnyRoute
+ '/methods/': typeof MethodsIndexRoute
+ '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren
+ '/api/params/$foo/$bar': typeof ApiParamsFooBarRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/methods'
+ | '/merge-middleware-context'
+ | '/api/middleware-context'
+ | '/api/only-any'
+ | '/methods/only-any'
+ | '/methods/'
+ | '/api/params/$foo'
+ | '/api/params/$foo/$bar'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/merge-middleware-context'
+ | '/api/middleware-context'
+ | '/api/only-any'
+ | '/methods/only-any'
+ | '/methods'
+ | '/api/params/$foo'
+ | '/api/params/$foo/$bar'
+ id:
+ | '__root__'
+ | '/'
+ | '/methods'
+ | '/merge-middleware-context'
+ | '/api/middleware-context'
+ | '/api/only-any'
+ | '/methods/only-any'
+ | '/methods/'
+ | '/api/params/$foo'
+ | '/api/params/$foo/$bar'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ MethodsRouteRoute: typeof MethodsRouteRouteWithChildren
+ MergeMiddlewareContextRoute: typeof MergeMiddlewareContextRoute
+ ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute
+ ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute
+ ApiParamsFooRouteRoute: typeof ApiParamsFooRouteRouteWithChildren
+}
+
+declare module '@tanstack/vue-router' {
+ interface FileRoutesByPath {
+ '/merge-middleware-context': {
+ id: '/merge-middleware-context'
+ path: '/merge-middleware-context'
+ fullPath: '/merge-middleware-context'
+ preLoaderRoute: typeof MergeMiddlewareContextRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/methods': {
+ id: '/methods'
+ path: '/methods'
+ fullPath: '/methods'
+ preLoaderRoute: typeof MethodsRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/methods/': {
+ id: '/methods/'
+ path: '/'
+ fullPath: '/methods/'
+ preLoaderRoute: typeof MethodsIndexRouteImport
+ parentRoute: typeof MethodsRouteRoute
+ }
+ '/methods/only-any': {
+ id: '/methods/only-any'
+ path: '/only-any'
+ fullPath: '/methods/only-any'
+ preLoaderRoute: typeof MethodsOnlyAnyRouteImport
+ parentRoute: typeof MethodsRouteRoute
+ }
+ '/api/only-any': {
+ id: '/api/only-any'
+ path: '/api/only-any'
+ fullPath: '/api/only-any'
+ preLoaderRoute: typeof ApiOnlyAnyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/middleware-context': {
+ id: '/api/middleware-context'
+ path: '/api/middleware-context'
+ fullPath: '/api/middleware-context'
+ preLoaderRoute: typeof ApiMiddlewareContextRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/params/$foo': {
+ id: '/api/params/$foo'
+ path: '/api/params/$foo'
+ fullPath: '/api/params/$foo'
+ preLoaderRoute: typeof ApiParamsFooRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/params/$foo/$bar': {
+ id: '/api/params/$foo/$bar'
+ path: '/$bar'
+ fullPath: '/api/params/$foo/$bar'
+ preLoaderRoute: typeof ApiParamsFooBarRouteImport
+ parentRoute: typeof ApiParamsFooRouteRoute
+ }
+ }
+}
+
+interface MethodsRouteRouteChildren {
+ MethodsOnlyAnyRoute: typeof MethodsOnlyAnyRoute
+ MethodsIndexRoute: typeof MethodsIndexRoute
+}
+
+const MethodsRouteRouteChildren: MethodsRouteRouteChildren = {
+ MethodsOnlyAnyRoute: MethodsOnlyAnyRoute,
+ MethodsIndexRoute: MethodsIndexRoute,
+}
+
+const MethodsRouteRouteWithChildren = MethodsRouteRoute._addFileChildren(
+ MethodsRouteRouteChildren,
+)
+
+interface ApiParamsFooRouteRouteChildren {
+ ApiParamsFooBarRoute: typeof ApiParamsFooBarRoute
+}
+
+const ApiParamsFooRouteRouteChildren: ApiParamsFooRouteRouteChildren = {
+ ApiParamsFooBarRoute: ApiParamsFooBarRoute,
+}
+
+const ApiParamsFooRouteRouteWithChildren =
+ ApiParamsFooRouteRoute._addFileChildren(ApiParamsFooRouteRouteChildren)
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ MethodsRouteRoute: MethodsRouteRouteWithChildren,
+ MergeMiddlewareContextRoute: MergeMiddlewareContextRoute,
+ ApiMiddlewareContextRoute: ApiMiddlewareContextRoute,
+ ApiOnlyAnyRoute: ApiOnlyAnyRoute,
+ ApiParamsFooRouteRoute: ApiParamsFooRouteRouteWithChildren,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/vue-start'
+declare module '@tanstack/vue-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/vue-start/server-routes/src/router.tsx b/e2e/vue-start/server-routes/src/router.tsx
new file mode 100644
index 00000000000..683d7776dd8
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/router.tsx
@@ -0,0 +1,20 @@
+import { createRouter } from '@tanstack/vue-router'
+import { setupRouterSsrQueryIntegration } from '@tanstack/vue-router-ssr-query'
+import { QueryClient } from '@tanstack/vue-query'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ const queryClient = new QueryClient()
+ const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ scrollRestoration: true,
+ })
+ setupRouterSsrQueryIntegration({ router, queryClient })
+
+ return router
+}
diff --git a/e2e/vue-start/server-routes/src/routes/__root.tsx b/e2e/vue-start/server-routes/src/routes/__root.tsx
new file mode 100644
index 00000000000..999d80fff1f
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/__root.tsx
@@ -0,0 +1,47 @@
+import {
+ Body,
+ HeadContent,
+ Html,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/vue-router'
+
+import { TanStackRouterDevtools } from '@tanstack/vue-router-devtools'
+import { NotFound } from '~/components/NotFound'
+import appCss from '~/styles/app.css?url'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ {
+ charSet: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ ],
+ links: [{ rel: 'stylesheet', href: appCss }],
+ }),
+ errorComponent: (props) => {
+ return {props.error.stack}
+ },
+ notFoundComponent: () => ,
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/routes/api/middleware-context.ts b/e2e/vue-start/server-routes/src/routes/api/middleware-context.ts
new file mode 100644
index 00000000000..d780d2ebb7b
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/api/middleware-context.ts
@@ -0,0 +1,29 @@
+import { createFileRoute } from '@tanstack/vue-router'
+import { createMiddleware, json } from '@tanstack/vue-start'
+
+const testParentMiddleware = createMiddleware().server(async ({ next }) => {
+ const result = await next({ context: { testParent: true } })
+ return result
+})
+
+const testMiddleware = createMiddleware()
+ .middleware([testParentMiddleware])
+ .server(async ({ next }) => {
+ const result = await next({ context: { test: true } })
+ return result
+ })
+
+export const Route = createFileRoute('/api/middleware-context')({
+ server: {
+ middleware: [testMiddleware],
+ handlers: {
+ GET: ({ request, context }) => {
+ return json({
+ url: request.url,
+ context: context,
+ expectedContext: { testParent: true, test: true },
+ })
+ },
+ },
+ },
+})
diff --git a/e2e/vue-start/server-routes/src/routes/api/only-any.ts b/e2e/vue-start/server-routes/src/routes/api/only-any.ts
new file mode 100644
index 00000000000..ded160acc18
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/api/only-any.ts
@@ -0,0 +1,18 @@
+import { createFileRoute } from '@tanstack/vue-router'
+import { json } from '@tanstack/vue-start'
+
+export const Route = createFileRoute('/api/only-any')({
+ server: {
+ handlers: {
+ ANY: ({ request }) => {
+ return json(
+ {
+ handler: 'ANY',
+ method: request.method,
+ },
+ { headers: { 'X-HANDLER': 'ANY', 'X-METHOD': request.method } },
+ )
+ },
+ },
+ },
+})
diff --git a/e2e/vue-start/server-routes/src/routes/api/params/$foo/$bar.ts b/e2e/vue-start/server-routes/src/routes/api/params/$foo/$bar.ts
new file mode 100644
index 00000000000..3f11e8287d8
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/api/params/$foo/$bar.ts
@@ -0,0 +1,11 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/api/params/$foo/$bar')({
+ server: {
+ handlers: {
+ GET: ({ params }) => {
+ return new Response('hello, ' + params.foo + ' and ' + params.bar)
+ },
+ },
+ },
+})
diff --git a/e2e/vue-start/server-routes/src/routes/api/params/$foo/route.ts b/e2e/vue-start/server-routes/src/routes/api/params/$foo/route.ts
new file mode 100644
index 00000000000..771e53025e9
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/api/params/$foo/route.ts
@@ -0,0 +1,11 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/api/params/$foo')({
+ server: {
+ handlers: {
+ GET: ({ params }) => {
+ return new Response('hello, ' + params.foo)
+ },
+ },
+ },
+})
diff --git a/e2e/vue-start/server-routes/src/routes/index.tsx b/e2e/vue-start/server-routes/src/routes/index.tsx
new file mode 100644
index 00000000000..78d831d3250
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/index.tsx
@@ -0,0 +1,23 @@
+import { Link, createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+
Server routes E2E tests
+
+ -
+
+ server route middleware context is merged correctly
+
+
+ -
+ server route methods
+
+
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/routes/merge-middleware-context.tsx b/e2e/vue-start/server-routes/src/routes/merge-middleware-context.tsx
new file mode 100644
index 00000000000..c0f655a4dad
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/merge-middleware-context.tsx
@@ -0,0 +1,75 @@
+import { createFileRoute } from '@tanstack/vue-router'
+import { defineComponent, ref } from 'vue'
+
+const MergeMiddlewareContext = defineComponent({
+ name: 'MergeMiddlewareContext',
+ setup() {
+ const apiResponse = ref(null)
+
+ const fetchMiddlewareContext = async () => {
+ try {
+ const response = await fetch('/api/middleware-context')
+ const data = await response.json()
+ apiResponse.value = data
+ } catch (error) {
+ console.error('Error fetching middleware context:', error)
+ apiResponse.value = { error: 'Failed to fetch' }
+ }
+ }
+
+ return () => (
+
+
Merge Server Route Middleware Context Test
+
+
+
+ {apiResponse.value ? (
+
+
API Response:
+
+ {JSON.stringify(apiResponse.value, null, 2)}
+
+
+
+
Context Verification:
+
+ {JSON.stringify(apiResponse.value?.context, null, 2)}
+
+
+
+ Has testParent:{' '}
+ {apiResponse.value?.context?.testParent ? 'true' : 'false'}
+
+
+
+ Has test:{' '}
+ {apiResponse.value?.context?.test ? 'true' : 'false'}
+
+
+
+ ) : null}
+
+
+ )
+ },
+})
+
+export const Route = createFileRoute('/merge-middleware-context')({
+ component: MergeMiddlewareContext,
+})
diff --git a/e2e/vue-start/server-routes/src/routes/methods/index.tsx b/e2e/vue-start/server-routes/src/routes/methods/index.tsx
new file mode 100644
index 00000000000..a46a0e66ad0
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/methods/index.tsx
@@ -0,0 +1,19 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/methods/')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return (
+
+
+ -
+
+ Server Route only has ANY handler
+
+
+
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/routes/methods/only-any.tsx b/e2e/vue-start/server-routes/src/routes/methods/only-any.tsx
new file mode 100644
index 00000000000..7c468e5ee98
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/methods/only-any.tsx
@@ -0,0 +1,80 @@
+import { createFileRoute } from '@tanstack/vue-router'
+import { useQuery } from '@tanstack/vue-query'
+import { defineComponent } from 'vue'
+
+export const Route = createFileRoute('/methods/only-any')({
+ ssr: false,
+ component: RouteComponent,
+})
+
+const HttpMethods = [
+ 'GET',
+ 'POST',
+ 'PUT',
+ 'DELETE',
+ 'PATCH',
+ 'OPTIONS',
+ 'HEAD',
+] as const
+type HttpMethods = (typeof HttpMethods)[number]
+
+type OnlyAnyApiResponse = {
+ method: HttpMethods
+ handler: 'ANY'
+}
+
+const Test = defineComponent({
+ name: 'OnlyAnyTest',
+ props: {
+ method: {
+ type: String,
+ required: true,
+ },
+ },
+ setup(props) {
+ const query = useQuery(() => ({
+ queryKey: ['only-any', props.method],
+ queryFn: async () => {
+ const method = props.method as HttpMethods
+ const response = await fetch(`/api/only-any`, { method })
+
+ try {
+ return (await response.json()) as OnlyAnyApiResponse
+ } catch {
+ // handle HEAD and OPTIONS that have no body
+ return {
+ handler: (response.headers.get('x-handler') ??
+ 'ANY') as OnlyAnyApiResponse['handler'],
+ method: (response.headers.get('x-method') ??
+ method) as OnlyAnyApiResponse['method'],
+ }
+ }
+ },
+ }))
+
+ return () => (
+
+
method={props.method}
+
expected
+
{props.method}
+
result
+ {query.data.value ? (
+
+ {query.data.value.method}
+
+ ) : null}
+
+ )
+ },
+})
+
+function RouteComponent() {
+ return (
+
+
Server Route has only ANY handler
+ {HttpMethods.map((method) => (
+
+ ))}
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/routes/methods/route.tsx b/e2e/vue-start/server-routes/src/routes/methods/route.tsx
new file mode 100644
index 00000000000..e39dc52bd8d
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/routes/methods/route.tsx
@@ -0,0 +1,14 @@
+import { Outlet, createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/methods')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return (
+
+
Server Routes Methods E2E tests
+
+
+ )
+}
diff --git a/e2e/vue-start/server-routes/src/styles/app.css b/e2e/vue-start/server-routes/src/styles/app.css
new file mode 100644
index 00000000000..c36c737cd46
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/styles/app.css
@@ -0,0 +1,30 @@
+@import 'tailwindcss';
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+@layer base {
+ html {
+ color-scheme: light dark;
+ }
+
+ * {
+ @apply border-gray-200 dark:border-gray-800;
+ }
+
+ html,
+ body {
+ @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200;
+ }
+
+ .using-mouse * {
+ outline: none !important;
+ }
+}
diff --git a/e2e/vue-start/server-routes/src/vite-env.d.ts b/e2e/vue-start/server-routes/src/vite-env.d.ts
new file mode 100644
index 00000000000..0b2af560d60
--- /dev/null
+++ b/e2e/vue-start/server-routes/src/vite-env.d.ts
@@ -0,0 +1,4 @@
+declare module '*?url' {
+ const url: string
+ export default url
+}
diff --git a/e2e/vue-start/server-routes/tests/server-routes.spec.ts b/e2e/vue-start/server-routes/tests/server-routes.spec.ts
new file mode 100644
index 00000000000..f3a80a5a469
--- /dev/null
+++ b/e2e/vue-start/server-routes/tests/server-routes.spec.ts
@@ -0,0 +1,47 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('merge-middleware-context', async ({ page }) => {
+ await page.goto('/merge-middleware-context')
+
+ await page.waitForLoadState('networkidle')
+
+ await page.getByTestId('test-middleware-context-btn').click()
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.getByTestId('has-test-parent')).toContainText('true')
+ await expect(page.getByTestId('has-test')).toContainText('true')
+
+ const contextResult = await page.getByTestId('context-result').textContent()
+ expect(contextResult).toContain('testParent')
+ expect(contextResult).toContain('test')
+})
+
+test.describe('methods', () => {
+ test('only ANY', async ({ page }) => {
+ await page.goto('/methods/only-any')
+
+ // wait for page to be loaded by waiting for the route component to be rendered
+ await expect(page.getByTestId('route-component')).toBeInViewport()
+
+ const testCases = await page
+ .locator('[data-testid^="expected-"]')
+ .elementHandles()
+ expect(testCases.length).not.toBe(0)
+ for (const testCase of testCases) {
+ const testId = await testCase.getAttribute('data-testid')
+
+ if (!testId) {
+ throw new Error('testcase is missing data-testid')
+ }
+
+ const suffix = testId.replace('expected-', '')
+
+ const expected =
+ (await page.getByTestId(`expected-${suffix}`).textContent()) || ''
+ expect(expected).not.toBe('')
+
+ await expect(page.getByTestId(`result-${suffix}`)).toContainText(expected)
+ }
+ })
+})
diff --git a/e2e/vue-start/server-routes/tsconfig.json b/e2e/vue-start/server-routes/tsconfig.json
new file mode 100644
index 00000000000..a5ae5ae7e47
--- /dev/null
+++ b/e2e/vue-start/server-routes/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["**/*.ts", "**/*.tsx", "public/script*.js"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "vue",
+ "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/vue-start/server-routes/vite.config.ts b/e2e/vue-start/server-routes/vite.config.ts
new file mode 100644
index 00000000000..79edfc05c26
--- /dev/null
+++ b/e2e/vue-start/server-routes/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/vue-start/plugin/vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+
+export default defineConfig({
+ server: {
+ port: 3000,
+ },
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart(),
+ vue(),
+ vueJsx(),
+ ],
+})
diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx
index 459d50f82c3..3e6b1615161 100644
--- a/packages/vue-router/src/index.tsx
+++ b/packages/vue-router/src/index.tsx
@@ -219,6 +219,7 @@ export type {
ActiveLinkOptions,
LinkProps,
LinkComponent,
+ LinkComponentRoute,
LinkComponentProps,
CreateLinkProps,
} from './link'
diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx
index 45ec178cbda..806fee28d52 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -630,6 +630,26 @@ export type LinkComponent = <
props: LinkComponentProps,
) => Vue.VNode
+export interface LinkComponentRoute<
+ in out TDefaultFrom extends string = string,
+> {
+ defaultFrom: TDefaultFrom
+ <
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TTo extends string | undefined = undefined,
+ const TMaskTo extends string = '',
+ >(
+ props: LinkComponentProps<
+ 'a',
+ TRouter,
+ this['defaultFrom'],
+ TTo,
+ this['defaultFrom'],
+ TMaskTo
+ >,
+ ): Vue.VNode
+}
+
export function createLink(
Comp: Constrain Vue.VNode>,
): LinkComponent {
diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts
index 458d70659d9..95295eceb00 100644
--- a/packages/vue-router/src/route.ts
+++ b/packages/vue-router/src/route.ts
@@ -4,6 +4,8 @@ import {
BaseRouteApi,
notFound,
} from '@tanstack/router-core'
+import * as Vue from 'vue'
+import { Link } from './link'
import { useLoaderData } from './useLoaderData'
import { useLoaderDeps } from './useLoaderDeps'
import { useParams } from './useParams'
@@ -42,8 +44,8 @@ import type { UseMatchRoute } from './useMatch'
import type { UseLoaderDepsRoute } from './useLoaderDeps'
import type { UseParamsRoute } from './useParams'
import type { UseSearchRoute } from './useSearch'
-import type * as Vue from 'vue'
import type { UseRouteContextRoute } from './useRouteContext'
+import type { LinkComponentRoute } from './link'
// Structural type for Vue SFC components (.vue files)
// Uses structural matching to accept Vue components without breaking
@@ -73,6 +75,7 @@ declare module '@tanstack/router-core' {
useLoaderDeps: UseLoaderDepsRoute
useLoaderData: UseLoaderDataRoute
useNavigate: () => UseNavigateResult
+ Link: LinkComponentRoute
}
}
@@ -140,6 +143,19 @@ export class RouteApi<
notFound = (opts?: NotFoundError) => {
return notFound({ routeId: this.id as string, ...opts })
}
+
+ Link: LinkComponentRoute['fullPath']> = ((
+ props,
+ ctx?: Vue.SetupContext,
+ ) => {
+ const router = useRouter()
+ const fullPath = router.routesById[this.id as string].fullPath
+ return Vue.h(
+ Link as any,
+ { from: fullPath as never, ...(props as any) },
+ ctx?.slots,
+ )
+ }) as LinkComponentRoute['fullPath']>
}
export class Route<
@@ -277,6 +293,14 @@ export class Route<
useNavigate = (): UseNavigateResult => {
return useNavigate({ from: this.fullPath })
}
+
+ Link: LinkComponentRoute = ((props, ctx?: Vue.SetupContext) => {
+ return Vue.h(
+ Link as any,
+ { from: this.fullPath as never, ...(props as any) },
+ ctx?.slots,
+ )
+ }) as LinkComponentRoute
}
export function createRoute<
@@ -515,6 +539,14 @@ export class RootRoute<
useNavigate = (): UseNavigateResult<'/'> => {
return useNavigate({ from: this.fullPath })
}
+
+ Link: LinkComponentRoute<'/'> = ((props, ctx?: Vue.SetupContext) => {
+ return Vue.h(
+ Link as any,
+ { from: this.fullPath as never, ...(props as any) },
+ ctx?.slots,
+ )
+ }) as LinkComponentRoute<'/'>
}
export function createRouteMask<
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9b41ca8d2f5..b6ff0805244 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4965,6 +4965,82 @@ importers:
specifier: ^3.1.8
version: 3.1.8(typescript@5.9.2)
+ e2e/vue-start/server-routes:
+ dependencies:
+ '@tanstack/vue-query':
+ specifier: ^5.90.9
+ version: 5.92.0(vue@3.5.25(typescript@5.9.2))
+ '@tanstack/vue-router':
+ specifier: workspace:*
+ version: link:../../../packages/vue-router
+ '@tanstack/vue-router-devtools':
+ specifier: workspace:*
+ version: link:../../../packages/vue-router-devtools
+ '@tanstack/vue-router-ssr-query':
+ specifier: workspace:*
+ version: link:../../../packages/vue-router-ssr-query
+ '@tanstack/vue-start':
+ specifier: workspace:*
+ version: link:../../../packages/vue-start
+ js-cookie:
+ specifier: ^3.0.5
+ version: 3.0.5
+ redaxios:
+ specifier: ^0.5.1
+ version: 0.5.1
+ tailwind-merge:
+ specifier: ^2.6.0
+ version: 2.6.0
+ vite:
+ specifier: ^7.1.7
+ version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ vue:
+ specifier: ^3.5.25
+ version: 3.5.25(typescript@5.9.2)
+ zod:
+ specifier: ^3.24.2
+ version: 3.25.57
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.56.1
+ '@tailwindcss/postcss':
+ specifier: ^4.1.15
+ version: 4.1.15
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/js-cookie':
+ specifier: ^3.0.6
+ version: 3.0.6
+ '@types/node':
+ specifier: 22.10.2
+ version: 22.10.2
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.3
+ version: 6.0.3(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2))
+ '@vitejs/plugin-vue-jsx':
+ specifier: ^5.1.2
+ version: 5.1.2(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2))
+ combinate:
+ specifier: ^1.1.11
+ version: 1.1.11
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.6
+ srvx:
+ specifier: ^0.8.6
+ version: 0.8.15
+ tailwindcss:
+ specifier: ^4.1.17
+ version: 4.1.17
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.2
+ vite-tsconfig-paths:
+ specifier: ^5.1.4
+ version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+
examples/react/authenticated-routes:
dependencies:
'@tailwindcss/postcss':