diff --git a/e2e/react-start/basic-nitro-spa/.gitignore b/e2e/react-start/basic-nitro-spa/.gitignore
new file mode 100644
index 00000000000..114d10aa0e4
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/.gitignore
@@ -0,0 +1,12 @@
+node_modules
+.DS_Store
+.cache
+.env
+dist
+.output
+.nitro
+
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/react-start/basic-nitro-spa/package.json b/e2e/react-start/basic-nitro-spa/package.json
new file mode 100644
index 00000000000..6edf2933e0e
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "tanstack-react-start-e2e-basic-nitro-spa",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev:v2": "vite dev -c vite.config.v2.ts --port 3000",
+ "dev:v3": "vite dev -c vite.config.v3.ts --port 3000",
+ "dev:e2e:v2": "vite dev -c vite.config.v2.ts",
+ "dev:e2e:v3": "vite dev -c vite.config.v3.ts",
+ "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit",
+ "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit",
+ "preview:v2": "vite preview -c vite.config.v2.ts",
+ "preview:v3": "vite preview -c vite.config.v3.ts",
+ "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium",
+ "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared",
+ "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared",
+ "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:^",
+ "@tanstack/react-router-devtools": "workspace:^",
+ "@tanstack/react-start": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.15",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@tanstack/nitro-v2-vite-plugin": "workspace:^",
+ "@types/node": "^22.10.2",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "nitro": "npm:nitro-nightly@latest",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^4.1.15",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/e2e/react-start/basic-nitro-spa/playwright.config.ts b/e2e/react-start/basic-nitro-spa/playwright.config.ts
new file mode 100644
index 00000000000..b26f5493b47
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+const nitroVariant = process.env.NITRO_VARIANT
+if (nitroVariant !== 'v2' && nitroVariant !== 'v3') {
+ throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.')
+}
+const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3'
+const buildCommand = `pnpm run ${buildScript}`
+
+/**
+ * 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: {
+ // Note: We run node directly instead of vite preview because Nitro's
+ // configurePreviewServer spawns on a random port. The prerendering during
+ // build uses vite.preview() correctly.
+ command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-start/basic-nitro-spa/postcss.config.mjs b/e2e/react-start/basic-nitro-spa/postcss.config.mjs
new file mode 100644
index 00000000000..a7f73a2d1d7
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
diff --git a/e2e/react-start/basic-nitro-spa/public/android-chrome-192x192.png b/e2e/react-start/basic-nitro-spa/public/android-chrome-192x192.png
new file mode 100644
index 00000000000..09c8324f8c6
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/android-chrome-192x192.png differ
diff --git a/e2e/react-start/basic-nitro-spa/public/android-chrome-512x512.png b/e2e/react-start/basic-nitro-spa/public/android-chrome-512x512.png
new file mode 100644
index 00000000000..11d626ea3d0
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/android-chrome-512x512.png differ
diff --git a/e2e/react-start/basic-nitro-spa/public/apple-touch-icon.png b/e2e/react-start/basic-nitro-spa/public/apple-touch-icon.png
new file mode 100644
index 00000000000..5a9423cc02c
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/apple-touch-icon.png differ
diff --git a/e2e/react-start/basic-nitro-spa/public/favicon-16x16.png b/e2e/react-start/basic-nitro-spa/public/favicon-16x16.png
new file mode 100644
index 00000000000..e3389b00443
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon-16x16.png differ
diff --git a/e2e/react-start/basic-nitro-spa/public/favicon-32x32.png b/e2e/react-start/basic-nitro-spa/public/favicon-32x32.png
new file mode 100644
index 00000000000..900c77d444c
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon-32x32.png differ
diff --git a/e2e/react-start/basic-nitro-spa/public/favicon.ico b/e2e/react-start/basic-nitro-spa/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon.ico differ
diff --git a/e2e/react-start/basic-nitro-spa/public/favicon.png b/e2e/react-start/basic-nitro-spa/public/favicon.png
new file mode 100644
index 00000000000..1e77bc06091
Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon.png differ
diff --git a/e2e/react-start/basic-nitro-spa/public/site.webmanifest b/e2e/react-start/basic-nitro-spa/public/site.webmanifest
new file mode 100644
index 00000000000..fa99de77db6
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000000..ef2daa1ea1d
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from '@tanstack/react-router'
+import type { ErrorComponentProps } from '@tanstack/react-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+
+ {isRoot ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/components/NotFound.tsx b/e2e/react-start/basic-nitro-spa/src/components/NotFound.tsx
new file mode 100644
index 00000000000..4e84e3f8e00
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/react-router'
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/routeTree.gen.ts b/e2e/react-start/basic-nitro-spa/src/routeTree.gen.ts
new file mode 100644
index 00000000000..4219501f5ef
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/routeTree.gen.ts
@@ -0,0 +1,86 @@
+/* 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 StaticRouteImport } from './routes/static'
+import { Route as IndexRouteImport } from './routes/index'
+
+const StaticRoute = StaticRouteImport.update({
+ id: '/static',
+ path: '/static',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/static'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/static'
+ id: '__root__' | '/' | '/static'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ StaticRoute: typeof StaticRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/static': {
+ id: '/static'
+ path: '/static'
+ fullPath: '/static'
+ preLoaderRoute: typeof StaticRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ StaticRoute: StaticRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/router.tsx b/e2e/react-start/basic-nitro-spa/src/router.tsx
new file mode 100644
index 00000000000..1a1d8822d20
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/router.tsx
@@ -0,0 +1,16 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ scrollRestoration: true,
+ })
+
+ return router
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/routes/__root.tsx b/e2e/react-start/basic-nitro-spa/src/routes/__root.tsx
new file mode 100644
index 00000000000..5b62b589077
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/routes/__root.tsx
@@ -0,0 +1,73 @@
+///
+import {
+ HeadContent,
+ Link,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
+import * as React from 'react'
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+import appCss from '~/styles/app.css?url'
+import { seo } from '~/utils/seo'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ {
+ charSet: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ ...seo({
+ title: 'TanStack Start + Nitro E2E Test',
+ description: 'Testing nitro integration with TanStack Start',
+ }),
+ ],
+ links: [
+ { rel: 'stylesheet', href: appCss },
+ { rel: 'icon', href: '/favicon.ico' },
+ ],
+ }),
+ errorComponent: DefaultCatchBoundary,
+ notFoundComponent: () => ,
+ shellComponent: RootDocument,
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ Home
+
+
+ Static
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/routes/index.tsx b/e2e/react-start/basic-nitro-spa/src/routes/index.tsx
new file mode 100644
index 00000000000..311e2cf3739
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/routes/index.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+
+export const Route = createFileRoute('/')({
+ loader: () => getData(),
+ component: Home,
+})
+
+const getData = createServerFn().handler(() => {
+ return {
+ message: 'Hello from Nitro server!',
+ timestamp: new Date().toISOString(),
+ }
+})
+
+function Home() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Welcome Home!
+
{data.message}
+
Loaded at: {data.timestamp}
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/routes/static.tsx b/e2e/react-start/basic-nitro-spa/src/routes/static.tsx
new file mode 100644
index 00000000000..f018bf39649
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/routes/static.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+
+export const Route = createFileRoute('/static')({
+ loader: () => getStaticData(),
+ component: StaticPage,
+})
+
+const getStaticData = createServerFn().handler(() => {
+ return {
+ content: 'This page was prerendered at build time',
+ buildTime: new Date().toISOString(),
+ }
+})
+
+function StaticPage() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Static Page
+
{data.content}
+
Build time: {data.buildTime}
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro-spa/src/styles/app.css b/e2e/react-start/basic-nitro-spa/src/styles/app.css
new file mode 100644
index 00000000000..c36c737cd46
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/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/react-start/basic-nitro-spa/src/utils/seo.ts b/e2e/react-start/basic-nitro-spa/src/utils/seo.ts
new file mode 100644
index 00000000000..d18ad84b74e
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/src/utils/seo.ts
@@ -0,0 +1,33 @@
+export const seo = ({
+ title,
+ description,
+ keywords,
+ image,
+}: {
+ title: string
+ description?: string
+ image?: string
+ keywords?: string
+}) => {
+ const tags = [
+ { title },
+ { name: 'description', content: description },
+ { name: 'keywords', content: keywords },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:description', content: description },
+ { name: 'twitter:creator', content: '@tannerlinsley' },
+ { name: 'twitter:site', content: '@tannerlinsley' },
+ { name: 'og:type', content: 'website' },
+ { name: 'og:title', content: title },
+ { name: 'og:description', content: description },
+ ...(image
+ ? [
+ { name: 'twitter:image', content: image },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'og:image', content: image },
+ ]
+ : []),
+ ]
+
+ return tags
+}
diff --git a/e2e/react-start/basic-nitro-spa/tests/app.spec.ts b/e2e/react-start/basic-nitro-spa/tests/app.spec.ts
new file mode 100644
index 00000000000..b0f96e373af
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/tests/app.spec.ts
@@ -0,0 +1,30 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { expect, test } from '@playwright/test'
+
+test('SPA shell is prerendered during build with nitro', async ({ page }) => {
+ const outputDir = join(process.cwd(), '.output', 'public')
+ expect(existsSync(join(outputDir, 'index.html'))).toBe(true)
+
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toBeVisible()
+})
+
+test('server functions work with nitro', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toHaveText('Welcome Home!')
+ await expect(page.getByTestId('message')).toHaveText(
+ 'Hello from Nitro server!',
+ )
+})
+
+test('client-side navigation works in SPA mode', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toBeVisible()
+
+ await page.click('a[href="/static"]')
+ await expect(page.getByTestId('static-heading')).toBeVisible()
+
+ await page.click('a[href="/"]')
+ await expect(page.getByTestId('home-heading')).toBeVisible()
+})
diff --git a/e2e/react-start/basic-nitro-spa/tsconfig.json b/e2e/react-start/basic-nitro-spa/tsconfig.json
new file mode 100644
index 00000000000..3a9fb7cd716
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/e2e/react-start/basic-nitro-spa/vite.config.v2.ts b/e2e/react-start/basic-nitro-spa/vite.config.v2.ts
new file mode 100644
index 00000000000..4b6e6dec6ae
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/vite.config.v2.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ spa: {
+ enabled: true,
+ prerender: {
+ outputPath: 'index.html',
+ },
+ },
+ }),
+ nitroV2Plugin(),
+ ],
+})
diff --git a/e2e/react-start/basic-nitro-spa/vite.config.v3.ts b/e2e/react-start/basic-nitro-spa/vite.config.v3.ts
new file mode 100644
index 00000000000..37f603e3ba8
--- /dev/null
+++ b/e2e/react-start/basic-nitro-spa/vite.config.v3.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import { nitro } from 'nitro/vite'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ spa: {
+ enabled: true,
+ prerender: {
+ outputPath: 'index.html',
+ },
+ },
+ }),
+ nitro(),
+ ],
+})
diff --git a/e2e/react-start/basic-nitro/.gitignore b/e2e/react-start/basic-nitro/.gitignore
new file mode 100644
index 00000000000..cce09e5f653
--- /dev/null
+++ b/e2e/react-start/basic-nitro/.gitignore
@@ -0,0 +1,11 @@
+node_modules
+.DS_Store
+.cache
+.env
+dist
+.output
+
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/react-start/basic-nitro/.prettierignore b/e2e/react-start/basic-nitro/.prettierignore
new file mode 100644
index 00000000000..a16e01379d7
--- /dev/null
+++ b/e2e/react-start/basic-nitro/.prettierignore
@@ -0,0 +1 @@
+routeTree.gen.ts
diff --git a/e2e/react-start/basic-nitro/package.json b/e2e/react-start/basic-nitro/package.json
new file mode 100644
index 00000000000..109d3e2d33f
--- /dev/null
+++ b/e2e/react-start/basic-nitro/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "tanstack-react-start-e2e-basic-nitro",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev:v2": "vite dev -c vite.config.v2.ts --port 3000",
+ "dev:v3": "vite dev -c vite.config.v3.ts --port 3000",
+ "dev:e2e:v2": "vite dev -c vite.config.v2.ts",
+ "dev:e2e:v3": "vite dev -c vite.config.v3.ts",
+ "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit",
+ "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit",
+ "preview:v2": "vite preview -c vite.config.v2.ts",
+ "preview:v3": "vite preview -c vite.config.v3.ts",
+ "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium",
+ "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared",
+ "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared",
+ "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:^",
+ "@tanstack/react-router-devtools": "workspace:^",
+ "@tanstack/react-start": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.15",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@tanstack/nitro-v2-vite-plugin": "workspace:^",
+ "@types/node": "^22.10.2",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "nitro": "npm:nitro-nightly@latest",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^4.1.15",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/e2e/react-start/basic-nitro/playwright.config.ts b/e2e/react-start/basic-nitro/playwright.config.ts
new file mode 100644
index 00000000000..b26f5493b47
--- /dev/null
+++ b/e2e/react-start/basic-nitro/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+const nitroVariant = process.env.NITRO_VARIANT
+if (nitroVariant !== 'v2' && nitroVariant !== 'v3') {
+ throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.')
+}
+const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3'
+const buildCommand = `pnpm run ${buildScript}`
+
+/**
+ * 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: {
+ // Note: We run node directly instead of vite preview because Nitro's
+ // configurePreviewServer spawns on a random port. The prerendering during
+ // build uses vite.preview() correctly.
+ command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-start/basic-nitro/postcss.config.mjs b/e2e/react-start/basic-nitro/postcss.config.mjs
new file mode 100644
index 00000000000..a7f73a2d1d7
--- /dev/null
+++ b/e2e/react-start/basic-nitro/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
diff --git a/e2e/react-start/basic-nitro/public/android-chrome-192x192.png b/e2e/react-start/basic-nitro/public/android-chrome-192x192.png
new file mode 100644
index 00000000000..09c8324f8c6
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/android-chrome-192x192.png differ
diff --git a/e2e/react-start/basic-nitro/public/android-chrome-512x512.png b/e2e/react-start/basic-nitro/public/android-chrome-512x512.png
new file mode 100644
index 00000000000..11d626ea3d0
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/android-chrome-512x512.png differ
diff --git a/e2e/react-start/basic-nitro/public/apple-touch-icon.png b/e2e/react-start/basic-nitro/public/apple-touch-icon.png
new file mode 100644
index 00000000000..5a9423cc02c
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/apple-touch-icon.png differ
diff --git a/e2e/react-start/basic-nitro/public/favicon-16x16.png b/e2e/react-start/basic-nitro/public/favicon-16x16.png
new file mode 100644
index 00000000000..e3389b00443
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon-16x16.png differ
diff --git a/e2e/react-start/basic-nitro/public/favicon-32x32.png b/e2e/react-start/basic-nitro/public/favicon-32x32.png
new file mode 100644
index 00000000000..900c77d444c
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon-32x32.png differ
diff --git a/e2e/react-start/basic-nitro/public/favicon.ico b/e2e/react-start/basic-nitro/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon.ico differ
diff --git a/e2e/react-start/basic-nitro/public/favicon.png b/e2e/react-start/basic-nitro/public/favicon.png
new file mode 100644
index 00000000000..1e77bc06091
Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon.png differ
diff --git a/e2e/react-start/basic-nitro/public/site.webmanifest b/e2e/react-start/basic-nitro/public/site.webmanifest
new file mode 100644
index 00000000000..fa99de77db6
--- /dev/null
+++ b/e2e/react-start/basic-nitro/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/e2e/react-start/basic-nitro/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/basic-nitro/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000000..ef2daa1ea1d
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from '@tanstack/react-router'
+import type { ErrorComponentProps } from '@tanstack/react-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+
+ {isRoot ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro/src/components/NotFound.tsx b/e2e/react-start/basic-nitro/src/components/NotFound.tsx
new file mode 100644
index 00000000000..4e84e3f8e00
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/react-router'
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro/src/routeTree.gen.ts b/e2e/react-start/basic-nitro/src/routeTree.gen.ts
new file mode 100644
index 00000000000..4219501f5ef
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/routeTree.gen.ts
@@ -0,0 +1,86 @@
+/* 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 StaticRouteImport } from './routes/static'
+import { Route as IndexRouteImport } from './routes/index'
+
+const StaticRoute = StaticRouteImport.update({
+ id: '/static',
+ path: '/static',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/static'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/static'
+ id: '__root__' | '/' | '/static'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ StaticRoute: typeof StaticRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/static': {
+ id: '/static'
+ path: '/static'
+ fullPath: '/static'
+ preLoaderRoute: typeof StaticRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ StaticRoute: StaticRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/react-start/basic-nitro/src/router.tsx b/e2e/react-start/basic-nitro/src/router.tsx
new file mode 100644
index 00000000000..1a1d8822d20
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/router.tsx
@@ -0,0 +1,16 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ scrollRestoration: true,
+ })
+
+ return router
+}
diff --git a/e2e/react-start/basic-nitro/src/routes/__root.tsx b/e2e/react-start/basic-nitro/src/routes/__root.tsx
new file mode 100644
index 00000000000..17577a3905e
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/routes/__root.tsx
@@ -0,0 +1,92 @@
+///
+import {
+ HeadContent,
+ Link,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
+import * as React from 'react'
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+import appCss from '~/styles/app.css?url'
+import { seo } from '~/utils/seo'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ {
+ charSet: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ ...seo({
+ title:
+ 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
+ description: `TanStack Start is a type-safe, client-first, full-stack React framework. `,
+ }),
+ ],
+ links: [
+ { rel: 'stylesheet', href: appCss },
+ {
+ rel: 'apple-touch-icon',
+ sizes: '180x180',
+ href: '/apple-touch-icon.png',
+ },
+ {
+ rel: 'icon',
+ type: 'image/png',
+ sizes: '32x32',
+ href: '/favicon-32x32.png',
+ },
+ {
+ rel: 'icon',
+ type: 'image/png',
+ sizes: '16x16',
+ href: '/favicon-16x16.png',
+ },
+ { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
+ { rel: 'icon', href: '/favicon.ico' },
+ ],
+ }),
+ errorComponent: DefaultCatchBoundary,
+ notFoundComponent: () => ,
+ shellComponent: RootDocument,
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ Home
+
+
+ Static
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro/src/routes/index.tsx b/e2e/react-start/basic-nitro/src/routes/index.tsx
new file mode 100644
index 00000000000..2b0878af588
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/routes/index.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+
+export const Route = createFileRoute('/')({
+ loader: () => getData(),
+ component: Home,
+})
+
+const getData = createServerFn().handler(() => {
+ return {
+ message: `Running in Node.js ${process.version}`,
+ runtime: 'Nitro',
+ }
+})
+
+function Home() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Welcome Home!!!
+
{data.message}
+
{data.runtime}
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro/src/routes/static.tsx b/e2e/react-start/basic-nitro/src/routes/static.tsx
new file mode 100644
index 00000000000..3333760d11a
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/routes/static.tsx
@@ -0,0 +1,28 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+
+export const Route = createFileRoute('/static')({
+ loader: () => getData(),
+ component: StaticPage,
+})
+
+const getData = createServerFn().handler(() => {
+ return {
+ generatedAt: new Date().toISOString(),
+ runtime: 'Nitro',
+ }
+})
+
+function StaticPage() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Static Page
+
+ This page was prerendered with {data.runtime}
+
+
Generated at: {data.generatedAt}
+
+ )
+}
diff --git a/e2e/react-start/basic-nitro/src/styles/app.css b/e2e/react-start/basic-nitro/src/styles/app.css
new file mode 100644
index 00000000000..c36c737cd46
--- /dev/null
+++ b/e2e/react-start/basic-nitro/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/react-start/basic-nitro/src/utils/seo.ts b/e2e/react-start/basic-nitro/src/utils/seo.ts
new file mode 100644
index 00000000000..d18ad84b74e
--- /dev/null
+++ b/e2e/react-start/basic-nitro/src/utils/seo.ts
@@ -0,0 +1,33 @@
+export const seo = ({
+ title,
+ description,
+ keywords,
+ image,
+}: {
+ title: string
+ description?: string
+ image?: string
+ keywords?: string
+}) => {
+ const tags = [
+ { title },
+ { name: 'description', content: description },
+ { name: 'keywords', content: keywords },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:description', content: description },
+ { name: 'twitter:creator', content: '@tannerlinsley' },
+ { name: 'twitter:site', content: '@tannerlinsley' },
+ { name: 'og:type', content: 'website' },
+ { name: 'og:title', content: title },
+ { name: 'og:description', content: description },
+ ...(image
+ ? [
+ { name: 'twitter:image', content: image },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'og:image', content: image },
+ ]
+ : []),
+ ]
+
+ return tags
+}
diff --git a/e2e/react-start/basic-nitro/tests/app.spec.ts b/e2e/react-start/basic-nitro/tests/app.spec.ts
new file mode 100644
index 00000000000..31b1a7f6251
--- /dev/null
+++ b/e2e/react-start/basic-nitro/tests/app.spec.ts
@@ -0,0 +1,38 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('returns correct runtime info', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('message')).toContainText('Running in Node.js')
+ await expect(page.getByTestId('runtime')).toHaveText('Nitro')
+})
+
+test('prerender with Nitro', async ({ page }) => {
+ const distDir = join(process.cwd(), '.output', 'public')
+ const staticHtmlPath = join(distDir, 'static', 'index.html')
+ expect(existsSync(staticHtmlPath)).toBe(true)
+ const staticHtml = readFileSync(staticHtmlPath, 'utf8')
+ const normalizedHtml = staticHtml.replace(//g, '')
+ expect(normalizedHtml).toContain('This page was prerendered with Nitro')
+ expect(normalizedHtml).toContain('Generated at:')
+
+ await page.goto('/static')
+ await expect(page.getByTestId('static-heading')).toHaveText('Static Page')
+ await expect(page.getByTestId('static-content')).toHaveText(
+ 'This page was prerendered with Nitro',
+ )
+ await expect(page.getByTestId('generated-at')).toContainText('Generated at:')
+})
+
+test('client-side navigation works', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('message')).toContainText('Running in Node.js')
+
+ await page.getByRole('link', { name: 'Static' }).click()
+ await expect(page.getByTestId('static-heading')).toHaveText('Static Page')
+
+ await page.getByRole('link', { name: 'Home' }).click()
+ await expect(page.getByTestId('message')).toContainText('Running in Node.js')
+})
diff --git a/e2e/react-start/basic-nitro/tests/setup/global.setup.ts b/e2e/react-start/basic-nitro/tests/setup/global.setup.ts
new file mode 100644
index 00000000000..f54c01cad2c
--- /dev/null
+++ b/e2e/react-start/basic-nitro/tests/setup/global.setup.ts
@@ -0,0 +1,3 @@
+export default async function setup() {
+ // No additional setup needed for Nitro
+}
diff --git a/e2e/react-start/basic-nitro/tests/setup/global.teardown.ts b/e2e/react-start/basic-nitro/tests/setup/global.teardown.ts
new file mode 100644
index 00000000000..ac5a84f5ea6
--- /dev/null
+++ b/e2e/react-start/basic-nitro/tests/setup/global.teardown.ts
@@ -0,0 +1,3 @@
+export default async function teardown() {
+ // No additional teardown needed for Nitro
+}
diff --git a/e2e/react-start/basic-nitro/tsconfig.json b/e2e/react-start/basic-nitro/tsconfig.json
new file mode 100644
index 00000000000..3a9fb7cd716
--- /dev/null
+++ b/e2e/react-start/basic-nitro/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/e2e/react-start/basic-nitro/vite.config.v2.ts b/e2e/react-start/basic-nitro/vite.config.v2.ts
new file mode 100644
index 00000000000..a10eb758e6e
--- /dev/null
+++ b/e2e/react-start/basic-nitro/vite.config.v2.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ prerender: {
+ enabled: true,
+ filter: (page) => page.path === '/static',
+ },
+ }),
+ nitroV2Plugin(),
+ ],
+})
diff --git a/e2e/react-start/basic-nitro/vite.config.v3.ts b/e2e/react-start/basic-nitro/vite.config.v3.ts
new file mode 100644
index 00000000000..eb7246636c4
--- /dev/null
+++ b/e2e/react-start/basic-nitro/vite.config.v3.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import { nitro } from 'nitro/vite'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ prerender: {
+ enabled: true,
+ filter: (page) => page.path === '/static',
+ },
+ }),
+ nitro(),
+ ],
+})
diff --git a/e2e/solid-start/basic-nitro-spa/.gitignore b/e2e/solid-start/basic-nitro-spa/.gitignore
new file mode 100644
index 00000000000..114d10aa0e4
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/.gitignore
@@ -0,0 +1,12 @@
+node_modules
+.DS_Store
+.cache
+.env
+dist
+.output
+.nitro
+
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/solid-start/basic-nitro-spa/package.json b/e2e/solid-start/basic-nitro-spa/package.json
new file mode 100644
index 00000000000..b6bde8093c3
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "tanstack-solid-start-e2e-basic-nitro-spa",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev:v2": "vite dev -c vite.config.v2.ts --port 3000",
+ "dev:v3": "vite dev -c vite.config.v3.ts --port 3000",
+ "dev:e2e:v2": "vite dev -c vite.config.v2.ts",
+ "dev:e2e:v3": "vite dev -c vite.config.v3.ts",
+ "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit",
+ "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit",
+ "preview:v2": "vite preview -c vite.config.v2.ts",
+ "preview:v3": "vite preview -c vite.config.v3.ts",
+ "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium",
+ "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared",
+ "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared",
+ "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3"
+ },
+ "dependencies": {
+ "@tanstack/solid-router": "workspace:^",
+ "@tanstack/solid-router-devtools": "workspace:^",
+ "@tanstack/solid-start": "workspace:^",
+ "solid-js": "^1.9.10"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.15",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@tanstack/nitro-v2-vite-plugin": "workspace:^",
+ "@types/node": "^22.10.2",
+ "nitro": "npm:nitro-nightly@latest",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^4.1.15",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "vite-plugin-solid": "^2.11.10",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/e2e/solid-start/basic-nitro-spa/playwright.config.ts b/e2e/solid-start/basic-nitro-spa/playwright.config.ts
new file mode 100644
index 00000000000..b26f5493b47
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+const nitroVariant = process.env.NITRO_VARIANT
+if (nitroVariant !== 'v2' && nitroVariant !== 'v3') {
+ throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.')
+}
+const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3'
+const buildCommand = `pnpm run ${buildScript}`
+
+/**
+ * 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: {
+ // Note: We run node directly instead of vite preview because Nitro's
+ // configurePreviewServer spawns on a random port. The prerendering during
+ // build uses vite.preview() correctly.
+ command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/solid-start/basic-nitro-spa/postcss.config.mjs b/e2e/solid-start/basic-nitro-spa/postcss.config.mjs
new file mode 100644
index 00000000000..a7f73a2d1d7
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
diff --git a/e2e/solid-start/basic-nitro-spa/public/android-chrome-192x192.png b/e2e/solid-start/basic-nitro-spa/public/android-chrome-192x192.png
new file mode 100644
index 00000000000..09c8324f8c6
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/android-chrome-192x192.png differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/android-chrome-512x512.png b/e2e/solid-start/basic-nitro-spa/public/android-chrome-512x512.png
new file mode 100644
index 00000000000..11d626ea3d0
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/android-chrome-512x512.png differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/apple-touch-icon.png b/e2e/solid-start/basic-nitro-spa/public/apple-touch-icon.png
new file mode 100644
index 00000000000..5a9423cc02c
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/apple-touch-icon.png differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon-16x16.png b/e2e/solid-start/basic-nitro-spa/public/favicon-16x16.png
new file mode 100644
index 00000000000..e3389b00443
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon-16x16.png differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon-32x32.png b/e2e/solid-start/basic-nitro-spa/public/favicon-32x32.png
new file mode 100644
index 00000000000..900c77d444c
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon-32x32.png differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon.ico b/e2e/solid-start/basic-nitro-spa/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon.ico differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon.png b/e2e/solid-start/basic-nitro-spa/public/favicon.png
new file mode 100644
index 00000000000..1e77bc06091
Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon.png differ
diff --git a/e2e/solid-start/basic-nitro-spa/public/site.webmanifest b/e2e/solid-start/basic-nitro-spa/public/site.webmanifest
new file mode 100644
index 00000000000..fa99de77db6
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx b/e2e/solid-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000000..2c0d464a066
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from '@tanstack/solid-router'
+import type { ErrorComponentProps } from '@tanstack/solid-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+
+ {isRoot() ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/components/NotFound.tsx b/e2e/solid-start/basic-nitro-spa/src/components/NotFound.tsx
new file mode 100644
index 00000000000..c48444862b5
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/solid-router'
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/routeTree.gen.ts b/e2e/solid-start/basic-nitro-spa/src/routeTree.gen.ts
new file mode 100644
index 00000000000..2bd11546dd6
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/routeTree.gen.ts
@@ -0,0 +1,86 @@
+/* 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 StaticRouteImport } from './routes/static'
+import { Route as IndexRouteImport } from './routes/index'
+
+const StaticRoute = StaticRouteImport.update({
+ id: '/static',
+ path: '/static',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/static'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/static'
+ id: '__root__' | '/' | '/static'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ StaticRoute: typeof StaticRoute
+}
+
+declare module '@tanstack/solid-router' {
+ interface FileRoutesByPath {
+ '/static': {
+ id: '/static'
+ path: '/static'
+ fullPath: '/static'
+ preLoaderRoute: typeof StaticRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ StaticRoute: StaticRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/solid-start'
+declare module '@tanstack/solid-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/router.tsx b/e2e/solid-start/basic-nitro-spa/src/router.tsx
new file mode 100644
index 00000000000..5da353c1ce2
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/router.tsx
@@ -0,0 +1,16 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ scrollRestoration: true,
+ })
+
+ return router
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/routes/__root.tsx b/e2e/solid-start/basic-nitro-spa/src/routes/__root.tsx
new file mode 100644
index 00000000000..7020b288737
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/routes/__root.tsx
@@ -0,0 +1,75 @@
+///
+import {
+ HeadContent,
+ Link,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/solid-router'
+import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools'
+import { HydrationScript } from 'solid-js/web'
+import type * as Solid from 'solid-js'
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+import appCss from '~/styles/app.css?url'
+import { seo } from '~/utils/seo'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ {
+ charset: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ ...seo({
+ title: 'TanStack Start + Nitro SPA E2E Test',
+ description: 'Testing nitro SPA integration with TanStack Start',
+ }),
+ ],
+ links: [
+ { rel: 'stylesheet', href: appCss },
+ { rel: 'icon', href: '/favicon.ico' },
+ ],
+ }),
+ errorComponent: DefaultCatchBoundary,
+ notFoundComponent: () => ,
+ shellComponent: RootDocument,
+})
+
+function RootDocument({ children }: { children: Solid.JSX.Element }) {
+ return (
+
+
+
+
+
+
+
+
+ Home
+
+
+ Static
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/routes/index.tsx b/e2e/solid-start/basic-nitro-spa/src/routes/index.tsx
new file mode 100644
index 00000000000..fdcf05ce075
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/routes/index.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+
+export const Route = createFileRoute('/')({
+ loader: () => getData(),
+ component: Home,
+})
+
+const getData = createServerFn().handler(() => {
+ return {
+ message: 'Hello from Nitro server!',
+ timestamp: new Date().toISOString(),
+ }
+})
+
+function Home() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Welcome Home!
+
{data().message}
+
Loaded at: {data().timestamp}
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/routes/static.tsx b/e2e/solid-start/basic-nitro-spa/src/routes/static.tsx
new file mode 100644
index 00000000000..55c781f286c
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/routes/static.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+
+export const Route = createFileRoute('/static')({
+ loader: () => getStaticData(),
+ component: StaticPage,
+})
+
+const getStaticData = createServerFn().handler(() => {
+ return {
+ content: 'This page was prerendered at build time',
+ buildTime: new Date().toISOString(),
+ }
+})
+
+function StaticPage() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Static Page
+
{data().content}
+
Build time: {data().buildTime}
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro-spa/src/styles/app.css b/e2e/solid-start/basic-nitro-spa/src/styles/app.css
new file mode 100644
index 00000000000..c36c737cd46
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/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/solid-start/basic-nitro-spa/src/utils/seo.ts b/e2e/solid-start/basic-nitro-spa/src/utils/seo.ts
new file mode 100644
index 00000000000..d18ad84b74e
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/src/utils/seo.ts
@@ -0,0 +1,33 @@
+export const seo = ({
+ title,
+ description,
+ keywords,
+ image,
+}: {
+ title: string
+ description?: string
+ image?: string
+ keywords?: string
+}) => {
+ const tags = [
+ { title },
+ { name: 'description', content: description },
+ { name: 'keywords', content: keywords },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:description', content: description },
+ { name: 'twitter:creator', content: '@tannerlinsley' },
+ { name: 'twitter:site', content: '@tannerlinsley' },
+ { name: 'og:type', content: 'website' },
+ { name: 'og:title', content: title },
+ { name: 'og:description', content: description },
+ ...(image
+ ? [
+ { name: 'twitter:image', content: image },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'og:image', content: image },
+ ]
+ : []),
+ ]
+
+ return tags
+}
diff --git a/e2e/solid-start/basic-nitro-spa/tests/app.spec.ts b/e2e/solid-start/basic-nitro-spa/tests/app.spec.ts
new file mode 100644
index 00000000000..b0f96e373af
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/tests/app.spec.ts
@@ -0,0 +1,30 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { expect, test } from '@playwright/test'
+
+test('SPA shell is prerendered during build with nitro', async ({ page }) => {
+ const outputDir = join(process.cwd(), '.output', 'public')
+ expect(existsSync(join(outputDir, 'index.html'))).toBe(true)
+
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toBeVisible()
+})
+
+test('server functions work with nitro', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toHaveText('Welcome Home!')
+ await expect(page.getByTestId('message')).toHaveText(
+ 'Hello from Nitro server!',
+ )
+})
+
+test('client-side navigation works in SPA mode', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toBeVisible()
+
+ await page.click('a[href="/static"]')
+ await expect(page.getByTestId('static-heading')).toBeVisible()
+
+ await page.click('a[href="/"]')
+ await expect(page.getByTestId('home-heading')).toBeVisible()
+})
diff --git a/e2e/solid-start/basic-nitro-spa/tsconfig.json b/e2e/solid-start/basic-nitro-spa/tsconfig.json
new file mode 100644
index 00000000000..ed8b73fa2dd
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "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,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/solid-start/basic-nitro-spa/vite.config.v2.ts b/e2e/solid-start/basic-nitro-spa/vite.config.v2.ts
new file mode 100644
index 00000000000..6677a660004
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/vite.config.v2.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin'
+import viteSolid from 'vite-plugin-solid'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ spa: {
+ enabled: true,
+ prerender: {
+ outputPath: 'index.html',
+ },
+ },
+ }),
+ viteSolid({ ssr: true }),
+ nitroV2Plugin(),
+ ],
+})
diff --git a/e2e/solid-start/basic-nitro-spa/vite.config.v3.ts b/e2e/solid-start/basic-nitro-spa/vite.config.v3.ts
new file mode 100644
index 00000000000..2a9b082bf2e
--- /dev/null
+++ b/e2e/solid-start/basic-nitro-spa/vite.config.v3.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+import { nitro } from 'nitro/vite'
+import viteSolid from 'vite-plugin-solid'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ spa: {
+ enabled: true,
+ prerender: {
+ outputPath: 'index.html',
+ },
+ },
+ }),
+ viteSolid({ ssr: true }),
+ nitro(),
+ ],
+})
diff --git a/e2e/solid-start/basic-nitro/.gitignore b/e2e/solid-start/basic-nitro/.gitignore
new file mode 100644
index 00000000000..114d10aa0e4
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/.gitignore
@@ -0,0 +1,12 @@
+node_modules
+.DS_Store
+.cache
+.env
+dist
+.output
+.nitro
+
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/solid-start/basic-nitro/.prettierignore b/e2e/solid-start/basic-nitro/.prettierignore
new file mode 100644
index 00000000000..a16e01379d7
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/.prettierignore
@@ -0,0 +1 @@
+routeTree.gen.ts
diff --git a/e2e/solid-start/basic-nitro/package.json b/e2e/solid-start/basic-nitro/package.json
new file mode 100644
index 00000000000..92363500e09
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "tanstack-solid-start-e2e-basic-nitro",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev:v2": "vite dev -c vite.config.v2.ts --port 3000",
+ "dev:v3": "vite dev -c vite.config.v3.ts --port 3000",
+ "dev:e2e:v2": "vite dev -c vite.config.v2.ts",
+ "dev:e2e:v3": "vite dev -c vite.config.v3.ts",
+ "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit",
+ "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit",
+ "preview:v2": "vite preview -c vite.config.v2.ts",
+ "preview:v3": "vite preview -c vite.config.v3.ts",
+ "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium",
+ "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared",
+ "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared",
+ "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3"
+ },
+ "dependencies": {
+ "@tanstack/solid-router": "workspace:^",
+ "@tanstack/solid-router-devtools": "workspace:^",
+ "@tanstack/solid-start": "workspace:^",
+ "solid-js": "^1.9.10"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.15",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@tanstack/nitro-v2-vite-plugin": "workspace:^",
+ "@types/node": "^22.10.2",
+ "nitro": "npm:nitro-nightly@latest",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^4.1.15",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "vite-plugin-solid": "^2.11.10",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/e2e/solid-start/basic-nitro/playwright.config.ts b/e2e/solid-start/basic-nitro/playwright.config.ts
new file mode 100644
index 00000000000..b26f5493b47
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+const nitroVariant = process.env.NITRO_VARIANT
+if (nitroVariant !== 'v2' && nitroVariant !== 'v3') {
+ throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.')
+}
+const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3'
+const buildCommand = `pnpm run ${buildScript}`
+
+/**
+ * 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: {
+ // Note: We run node directly instead of vite preview because Nitro's
+ // configurePreviewServer spawns on a random port. The prerendering during
+ // build uses vite.preview() correctly.
+ command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/solid-start/basic-nitro/postcss.config.mjs b/e2e/solid-start/basic-nitro/postcss.config.mjs
new file mode 100644
index 00000000000..a7f73a2d1d7
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
diff --git a/e2e/solid-start/basic-nitro/public/android-chrome-192x192.png b/e2e/solid-start/basic-nitro/public/android-chrome-192x192.png
new file mode 100644
index 00000000000..09c8324f8c6
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/android-chrome-192x192.png differ
diff --git a/e2e/solid-start/basic-nitro/public/android-chrome-512x512.png b/e2e/solid-start/basic-nitro/public/android-chrome-512x512.png
new file mode 100644
index 00000000000..11d626ea3d0
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/android-chrome-512x512.png differ
diff --git a/e2e/solid-start/basic-nitro/public/apple-touch-icon.png b/e2e/solid-start/basic-nitro/public/apple-touch-icon.png
new file mode 100644
index 00000000000..5a9423cc02c
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/apple-touch-icon.png differ
diff --git a/e2e/solid-start/basic-nitro/public/favicon-16x16.png b/e2e/solid-start/basic-nitro/public/favicon-16x16.png
new file mode 100644
index 00000000000..e3389b00443
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon-16x16.png differ
diff --git a/e2e/solid-start/basic-nitro/public/favicon-32x32.png b/e2e/solid-start/basic-nitro/public/favicon-32x32.png
new file mode 100644
index 00000000000..900c77d444c
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon-32x32.png differ
diff --git a/e2e/solid-start/basic-nitro/public/favicon.ico b/e2e/solid-start/basic-nitro/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon.ico differ
diff --git a/e2e/solid-start/basic-nitro/public/favicon.png b/e2e/solid-start/basic-nitro/public/favicon.png
new file mode 100644
index 00000000000..1e77bc06091
Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon.png differ
diff --git a/e2e/solid-start/basic-nitro/public/site.webmanifest b/e2e/solid-start/basic-nitro/public/site.webmanifest
new file mode 100644
index 00000000000..fa99de77db6
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/e2e/solid-start/basic-nitro/src/components/DefaultCatchBoundary.tsx b/e2e/solid-start/basic-nitro/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000000..2c0d464a066
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from '@tanstack/solid-router'
+import type { ErrorComponentProps } from '@tanstack/solid-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+
+ {isRoot() ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro/src/components/NotFound.tsx b/e2e/solid-start/basic-nitro/src/components/NotFound.tsx
new file mode 100644
index 00000000000..c48444862b5
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from '@tanstack/solid-router'
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro/src/routeTree.gen.ts b/e2e/solid-start/basic-nitro/src/routeTree.gen.ts
new file mode 100644
index 00000000000..2bd11546dd6
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/routeTree.gen.ts
@@ -0,0 +1,86 @@
+/* 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 StaticRouteImport } from './routes/static'
+import { Route as IndexRouteImport } from './routes/index'
+
+const StaticRoute = StaticRouteImport.update({
+ id: '/static',
+ path: '/static',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/static': typeof StaticRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/static'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/static'
+ id: '__root__' | '/' | '/static'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ StaticRoute: typeof StaticRoute
+}
+
+declare module '@tanstack/solid-router' {
+ interface FileRoutesByPath {
+ '/static': {
+ id: '/static'
+ path: '/static'
+ fullPath: '/static'
+ preLoaderRoute: typeof StaticRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ StaticRoute: StaticRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/solid-start'
+declare module '@tanstack/solid-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/solid-start/basic-nitro/src/router.tsx b/e2e/solid-start/basic-nitro/src/router.tsx
new file mode 100644
index 00000000000..5da353c1ce2
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/router.tsx
@@ -0,0 +1,16 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ scrollRestoration: true,
+ })
+
+ return router
+}
diff --git a/e2e/solid-start/basic-nitro/src/routes/__root.tsx b/e2e/solid-start/basic-nitro/src/routes/__root.tsx
new file mode 100644
index 00000000000..9d2f56b6756
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/routes/__root.tsx
@@ -0,0 +1,94 @@
+///
+import {
+ HeadContent,
+ Link,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/solid-router'
+import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools'
+import { HydrationScript } from 'solid-js/web'
+import type * as Solid from 'solid-js'
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+import appCss from '~/styles/app.css?url'
+import { seo } from '~/utils/seo'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ {
+ charset: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ ...seo({
+ title:
+ 'TanStack Start | Type-Safe, Client-First, Full-Stack Solid Framework',
+ description: `TanStack Start is a type-safe, client-first, full-stack Solid framework. `,
+ }),
+ ],
+ links: [
+ { rel: 'stylesheet', href: appCss },
+ {
+ rel: 'apple-touch-icon',
+ sizes: '180x180',
+ href: '/apple-touch-icon.png',
+ },
+ {
+ rel: 'icon',
+ type: 'image/png',
+ sizes: '32x32',
+ href: '/favicon-32x32.png',
+ },
+ {
+ rel: 'icon',
+ type: 'image/png',
+ sizes: '16x16',
+ href: '/favicon-16x16.png',
+ },
+ { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
+ { rel: 'icon', href: '/favicon.ico' },
+ ],
+ }),
+ errorComponent: DefaultCatchBoundary,
+ notFoundComponent: () => ,
+ shellComponent: RootDocument,
+})
+
+function RootDocument({ children }: { children: Solid.JSX.Element }) {
+ return (
+
+
+
+
+
+
+
+
+ Home
+
+
+ Static
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro/src/routes/index.tsx b/e2e/solid-start/basic-nitro/src/routes/index.tsx
new file mode 100644
index 00000000000..681a335b355
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/routes/index.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+
+export const Route = createFileRoute('/')({
+ loader: () => getData(),
+ component: Home,
+})
+
+const getData = createServerFn().handler(() => {
+ return {
+ message: `Running in ${typeof navigator !== 'undefined' ? navigator.userAgent : 'Unknown'}`,
+ runtime: 'Nitro',
+ }
+})
+
+function Home() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Welcome Home!!!
+
{data().message}
+
{data().runtime}
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro/src/routes/static.tsx b/e2e/solid-start/basic-nitro/src/routes/static.tsx
new file mode 100644
index 00000000000..5d7a478efd6
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/routes/static.tsx
@@ -0,0 +1,14 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/static')({
+ component: StaticPage,
+})
+
+function StaticPage() {
+ return (
+
+
Static Page
+
This page was prerendered with Nitro
+
+ )
+}
diff --git a/e2e/solid-start/basic-nitro/src/styles/app.css b/e2e/solid-start/basic-nitro/src/styles/app.css
new file mode 100644
index 00000000000..c36c737cd46
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/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/solid-start/basic-nitro/src/utils/seo.ts b/e2e/solid-start/basic-nitro/src/utils/seo.ts
new file mode 100644
index 00000000000..d18ad84b74e
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/src/utils/seo.ts
@@ -0,0 +1,33 @@
+export const seo = ({
+ title,
+ description,
+ keywords,
+ image,
+}: {
+ title: string
+ description?: string
+ image?: string
+ keywords?: string
+}) => {
+ const tags = [
+ { title },
+ { name: 'description', content: description },
+ { name: 'keywords', content: keywords },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:description', content: description },
+ { name: 'twitter:creator', content: '@tannerlinsley' },
+ { name: 'twitter:site', content: '@tannerlinsley' },
+ { name: 'og:type', content: 'website' },
+ { name: 'og:title', content: title },
+ { name: 'og:description', content: description },
+ ...(image
+ ? [
+ { name: 'twitter:image', content: image },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'og:image', content: image },
+ ]
+ : []),
+ ]
+
+ return tags
+}
diff --git a/e2e/solid-start/basic-nitro/tests/app.spec.ts b/e2e/solid-start/basic-nitro/tests/app.spec.ts
new file mode 100644
index 00000000000..c48116ea805
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/tests/app.spec.ts
@@ -0,0 +1,36 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('returns correct runtime info', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('message')).toContainText('Running in Node.js')
+ await expect(page.getByTestId('runtime')).toHaveText('Nitro')
+})
+
+test('prerender with Nitro', async ({ page }) => {
+ const distDir = join(process.cwd(), '.output', 'public')
+ const staticHtmlPath = join(distDir, 'static', 'index.html')
+ expect(existsSync(staticHtmlPath)).toBe(true)
+ const staticHtml = readFileSync(staticHtmlPath, 'utf8')
+ const normalizedHtml = staticHtml.replace(//g, '')
+ expect(normalizedHtml).toContain('This page was prerendered with Nitro')
+
+ await page.goto('/static')
+ await expect(page.getByTestId('static-heading')).toHaveText('Static Page')
+ await expect(page.getByTestId('static-content')).toHaveText(
+ 'This page was prerendered with Nitro',
+ )
+})
+
+test('client-side navigation works', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('message')).toContainText('Running in Node.js')
+
+ await page.getByRole('link', { name: 'Static' }).click()
+ await expect(page.getByTestId('static-heading')).toHaveText('Static Page')
+
+ await page.getByRole('link', { name: 'Home' }).click()
+ await expect(page.getByTestId('message')).toContainText('Running in Node.js')
+})
diff --git a/e2e/solid-start/basic-nitro/tsconfig.json b/e2e/solid-start/basic-nitro/tsconfig.json
new file mode 100644
index 00000000000..ed8b73fa2dd
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "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,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/solid-start/basic-nitro/vite.config.v2.ts b/e2e/solid-start/basic-nitro/vite.config.v2.ts
new file mode 100644
index 00000000000..15ad2fabfc3
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/vite.config.v2.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin'
+import viteSolid from 'vite-plugin-solid'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ prerender: {
+ enabled: true,
+ filter: (page) => page.path === '/static',
+ },
+ }),
+ viteSolid({ ssr: true }),
+ nitroV2Plugin(),
+ ],
+})
diff --git a/e2e/solid-start/basic-nitro/vite.config.v3.ts b/e2e/solid-start/basic-nitro/vite.config.v3.ts
new file mode 100644
index 00000000000..5f6a5f48e73
--- /dev/null
+++ b/e2e/solid-start/basic-nitro/vite.config.v3.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import tsConfigPaths from 'vite-tsconfig-paths'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+import { nitro } from 'nitro/vite'
+import viteSolid from 'vite-plugin-solid'
+
+export default defineConfig({
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ tanstackStart({
+ prerender: {
+ enabled: true,
+ filter: (page) => page.path === '/static',
+ },
+ }),
+ viteSolid({ ssr: true }),
+ nitro(),
+ ],
+})
diff --git a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts
index fccbfb09de0..3537a1eb0c3 100644
--- a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts
@@ -101,7 +101,7 @@ export function devServerPlugin({
console.error(e)
try {
viteDevServer.ssrFixStacktrace(e as Error)
- } catch (_e) {}
+ } catch {}
if (
webReq.headers.get('content-type')?.includes('application/json')
diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts
index ba193cda0a2..725652de404 100644
--- a/packages/start-plugin-core/src/plugin.ts
+++ b/packages/start-plugin-core/src/plugin.ts
@@ -15,7 +15,7 @@ import {
getClientOutputDirectory,
getServerOutputDirectory,
} from './output-directory'
-import { postServerBuild } from './post-server-build'
+import { postServerBuild, postServerBuildForNitro } from './post-server-build'
import { startCompilerPlugin } from './start-compiler-plugin/plugin'
import type {
GetConfigFn,
@@ -38,6 +38,37 @@ function isFullUrl(str: string): boolean {
}
}
+type NitroPluginKind = 'v3' | 'v2' | null
+
+function getNitroPluginKind(
+ plugins: ReadonlyArray,
+): NitroPluginKind {
+ let hasV3 = false
+ let hasV2 = false
+ const queue = [...plugins]
+
+ while (queue.length) {
+ const plugin = queue.shift()
+ if (!plugin) continue
+ if (Array.isArray(plugin)) {
+ queue.push(...plugin)
+ continue
+ }
+ if (typeof plugin === 'object' && 'name' in plugin) {
+ const name = typeof plugin.name === 'string' ? plugin.name : ''
+ if (name.startsWith('nitro:')) {
+ hasV3 = true
+ } else if (name === 'tanstack-nitro-v2-vite-plugin') {
+ hasV2 = true
+ }
+ }
+ }
+
+ if (hasV3) return 'v3'
+ if (hasV2) return 'v2'
+ return null
+}
+
export function TanStackStartVitePluginCore(
corePluginOpts: TanStackStartVitePluginCoreOptions,
startPluginOpts: TanStackStartInputConfig,
@@ -351,14 +382,74 @@ export function TanStackStartVitePluginCore(
// would cause this hook to run before those others' buildApp, breaking prerendering.
{
name: 'tanstack-start-core:post-build',
+ configResolved(config) {
+ resolvedStartConfig.viteConfigFile = config.configFile || undefined
+ },
buildApp: {
order: 'post',
async handler(builder) {
const { startConfig } = getConfig()
- await postServerBuild({ builder, startConfig })
+ const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server]
+ if (!serverEnv) {
+ throw new Error('SSR environment not found')
+ }
+ const nitroKindFromPlugins = getNitroPluginKind(
+ builder.config.plugins,
+ )
+ const nitroKind =
+ nitroKindFromPlugins ??
+ (serverEnv.config.build.write === false ? 'v2' : null)
+
+ if (nitroKind === 'v3') {
+ return
+ }
+
+ if (nitroKind === 'v2') {
+ await postServerBuildForNitro({
+ startConfig,
+ mode: 'nitro-server',
+ nitro: {
+ options: {
+ rootDir: builder.config.root,
+ output: {
+ dir: '.output',
+ publicDir: '.output/public',
+ },
+ },
+ },
+ })
+ return
+ }
+
+ await postServerBuild({
+ builder,
+ startConfig,
+ })
},
},
},
+ // Nitro module plugin - runs prerendering after Nitro build using vite preview
+ {
+ name: 'tanstack-start-core:nitro-prerender',
+ nitro: {
+ name: 'tanstack-start-prerender',
+ setup(nitro: any) {
+ nitro.hooks.hook('compiled', async () => {
+ const { startConfig, resolvedStartConfig } = getConfig()
+ if (!startConfig.prerender?.enabled && !startConfig.spa?.enabled) {
+ return
+ }
+
+ await postServerBuildForNitro({
+ startConfig,
+ nitro,
+ mode: 'vite-preview',
+ configFile: resolvedStartConfig.viteConfigFile,
+ })
+ })
+ },
+ },
+ } as PluginOption,
// Server function plugin handles:
// 1. Identifying createServerFn().handler() calls
// 2. Extracting server functions to separate modules
diff --git a/packages/start-plugin-core/src/post-server-build.ts b/packages/start-plugin-core/src/post-server-build.ts
index 109b5a680c5..4459aaeb0f8 100644
--- a/packages/start-plugin-core/src/post-server-build.ts
+++ b/packages/start-plugin-core/src/post-server-build.ts
@@ -1,57 +1,23 @@
-import { HEADERS } from '@tanstack/start-server-core'
import { buildSitemap } from './build-sitemap'
import { VITE_ENVIRONMENT_NAMES } from './constants'
+import { setupPrerenderConfig } from './prerender-config'
import { prerender } from './prerender'
+import { prerenderWithNitro, resolveNitroOutputPaths } from './prerender-nitro'
import type { TanStackStartOutputConfig } from './schema'
import type { ViteBuilder } from 'vite'
export async function postServerBuild({
builder,
startConfig,
+ skipPrerender = false,
}: {
builder: ViteBuilder
startConfig: TanStackStartOutputConfig
+ skipPrerender?: boolean
}) {
- // If the user has not set a prerender option, we need to set it to true
- // if the pages array is not empty and has sub options requiring for prerendering
- // If the user has explicitly set prerender.enabled, this should be respected
- if (startConfig.prerender?.enabled !== false) {
- startConfig.prerender = {
- ...startConfig.prerender,
- enabled:
- startConfig.prerender?.enabled ??
- startConfig.pages.some((d) =>
- typeof d === 'string' ? false : !!d.prerender?.enabled,
- ),
- }
- }
-
- // Setup the options for prerendering the SPA shell (i.e `src/routes/__root.tsx`)
- if (startConfig.spa?.enabled) {
- startConfig.prerender = {
- ...startConfig.prerender,
- enabled: true,
- }
+ setupPrerenderConfig(startConfig)
- const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost')
-
- startConfig.pages.push({
- path: maskUrl.toString().replace('http://localhost', ''),
- prerender: {
- ...startConfig.spa.prerender,
- headers: {
- ...startConfig.spa.prerender.headers,
- [HEADERS.TSS_SHELL]: 'true',
- },
- },
- sitemap: {
- exclude: true,
- },
- })
- }
-
- // Run the prerendering process
- if (startConfig.prerender.enabled) {
+ if (startConfig.prerender?.enabled && !skipPrerender) {
await prerender({
startConfig,
builder,
@@ -68,3 +34,43 @@ export async function postServerBuild({
})
}
}
+
+export async function postServerBuildForNitro({
+ startConfig,
+ nitro,
+ mode,
+ configFile,
+}: {
+ startConfig: TanStackStartOutputConfig
+ nitro: {
+ options: {
+ rootDir: string
+ output?: {
+ dir?: string
+ publicDir?: string
+ }
+ preset?: string
+ }
+ }
+ mode: 'vite-preview' | 'nitro-server'
+ configFile?: string
+}) {
+ setupPrerenderConfig(startConfig)
+
+ if (startConfig.prerender?.enabled) {
+ await prerenderWithNitro({
+ startConfig,
+ nitro,
+ mode,
+ configFile,
+ })
+ }
+
+ if (startConfig.sitemap?.enabled && startConfig.pages.length) {
+ const { publicDir } = resolveNitroOutputPaths(nitro)
+ buildSitemap({
+ startConfig,
+ publicDir,
+ })
+ }
+}
diff --git a/packages/start-plugin-core/src/prerender-config.ts b/packages/start-plugin-core/src/prerender-config.ts
new file mode 100644
index 00000000000..eb63abc1021
--- /dev/null
+++ b/packages/start-plugin-core/src/prerender-config.ts
@@ -0,0 +1,38 @@
+import { HEADERS } from '@tanstack/start-server-core'
+import type { TanStackStartOutputConfig } from './schema'
+
+export function setupPrerenderConfig(startConfig: TanStackStartOutputConfig) {
+ if (startConfig.prerender?.enabled !== false) {
+ startConfig.prerender = {
+ ...startConfig.prerender,
+ enabled:
+ startConfig.prerender?.enabled ??
+ startConfig.pages.some((d) =>
+ typeof d === 'string' ? false : !!d.prerender?.enabled,
+ ),
+ }
+ }
+
+ if (startConfig.spa?.enabled) {
+ startConfig.prerender = {
+ ...startConfig.prerender,
+ enabled: true,
+ }
+
+ const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost')
+
+ startConfig.pages.push({
+ path: maskUrl.toString().replace('http://localhost', ''),
+ prerender: {
+ ...startConfig.spa.prerender,
+ headers: {
+ ...startConfig.spa.prerender.headers,
+ [HEADERS.TSS_SHELL]: 'true',
+ },
+ },
+ sitemap: {
+ exclude: true,
+ },
+ })
+ }
+}
diff --git a/packages/start-plugin-core/src/prerender-nitro.ts b/packages/start-plugin-core/src/prerender-nitro.ts
new file mode 100644
index 00000000000..f9a146623cd
--- /dev/null
+++ b/packages/start-plugin-core/src/prerender-nitro.ts
@@ -0,0 +1,98 @@
+import { existsSync, promises as fsp } from 'node:fs'
+import path from 'pathe'
+import { prerender } from './prerender'
+import { createLogger } from './utils'
+import type { TanStackStartOutputConfig } from './schema'
+
+type NitroLike = {
+ options: {
+ rootDir: string
+ output?: {
+ dir?: string
+ publicDir?: string
+ }
+ preset?: string
+ }
+}
+
+type PrerenderMode = 'vite-preview' | 'nitro-server'
+
+export function resolveNitroOutputPaths(nitro: NitroLike) {
+ const rootDir = nitro.options.rootDir
+ const output = nitro.options.output ?? {}
+ const outputDir = resolveNitroPath(rootDir, output.dir ?? '.output')
+ const publicDir = resolveNitroPath(
+ rootDir,
+ output.publicDir ?? path.join(output.dir ?? '.output', 'public'),
+ )
+
+ return { outputDir, publicDir }
+}
+
+export async function prerenderWithNitro({
+ startConfig,
+ nitro,
+ mode,
+ configFile,
+}: {
+ startConfig: TanStackStartOutputConfig
+ nitro: NitroLike
+ mode: PrerenderMode
+ configFile?: string
+}) {
+ const logger = createLogger('prerender')
+ const { outputDir, publicDir } = resolveNitroOutputPaths(nitro)
+
+ if (mode === 'vite-preview') {
+ const nitroJsonPath = path.join(outputDir, 'nitro.json')
+ if (!existsSync(nitroJsonPath)) {
+ await writeNitroBuildInfo({
+ outputDir,
+ preset: nitro.options.preset,
+ })
+ }
+
+ logger.info('Prerendering pages using vite.preview()...')
+ await prerender({
+ startConfig,
+ outputDir: publicDir,
+ configFile,
+ })
+ return
+ }
+
+ logger.info('Prerendering pages using Nitro server...')
+ await prerender({
+ startConfig,
+ outputDir: publicDir,
+ nitroServerPath: path.join(outputDir, 'server/index.mjs'),
+ })
+}
+
+async function writeNitroBuildInfo({
+ outputDir,
+ preset,
+}: {
+ outputDir: string
+ preset?: string
+}) {
+ const logger = createLogger('prerender')
+ logger.info('Writing nitro.json for vite.preview()...')
+
+ const buildInfo = {
+ date: new Date().toJSON(),
+ preset,
+ framework: { name: 'tanstack-start' },
+ versions: {},
+ commands: {
+ preview: `node ${path.join(outputDir, 'server/index.mjs')}`,
+ },
+ }
+
+ const buildInfoPath = path.join(outputDir, 'nitro.json')
+ await fsp.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2))
+}
+
+function resolveNitroPath(rootDir: string, value: string) {
+ return path.isAbsolute(value) ? value : path.resolve(rootDir, value)
+}
diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts
index ea6368109e5..ee8b872a95a 100644
--- a/packages/start-plugin-core/src/prerender.ts
+++ b/packages/start-plugin-core/src/prerender.ts
@@ -1,19 +1,28 @@
import { promises as fsp } from 'node:fs'
+import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'pathe'
import { joinURL, withBase, withoutBase } from 'ufo'
import { VITE_ENVIRONMENT_NAMES } from './constants'
import { createLogger } from './utils'
import { Queue } from './queue'
-import type { PreviewServer, ResolvedConfig, ViteBuilder } from 'vite'
+import type { ChildProcess } from 'node:child_process'
+import type { PreviewServer, ViteBuilder } from 'vite'
import type { Page, TanStackStartOutputConfig } from './schema'
export async function prerender({
startConfig,
builder,
+ outputDir: outputDirOverride,
+ configFile: configFileOverride,
+ nitroServerPath,
}: {
startConfig: TanStackStartOutputConfig
- builder: ViteBuilder
+ builder?: ViteBuilder
+ outputDir?: string
+ configFile?: string
+ /** Path to Nitro's compiled server entry (e.g., .output/server/index.mjs) */
+ nitroServerPath?: string
}) {
const logger = createLogger('prerender')
logger.info('Prerendering pages...')
@@ -40,38 +49,54 @@ export async function prerender({
startConfig.pages = pages
}
- const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server]
+ let outputDir: string
- if (!serverEnv) {
- throw new Error(
- `Vite's "${VITE_ENVIRONMENT_NAMES.server}" environment not found`,
- )
- }
-
- const clientEnv = builder.environments[VITE_ENVIRONMENT_NAMES.client]
- if (!clientEnv) {
- throw new Error(
- `Vite's "${VITE_ENVIRONMENT_NAMES.client}" environment not found`,
- )
+ if (outputDirOverride) {
+ outputDir = outputDirOverride
+ } else if (builder) {
+ const clientEnv = builder.environments[VITE_ENVIRONMENT_NAMES.client]
+ if (!clientEnv) {
+ throw new Error(
+ `Vite's "${VITE_ENVIRONMENT_NAMES.client}" environment not found`,
+ )
+ }
+ outputDir = clientEnv.config.build.outDir
+ } else {
+ throw new Error('Either builder or outputDir must be provided')
}
- const outputDir = clientEnv.config.build.outDir
-
process.env.TSS_PRERENDERING = 'true'
- // Start Vite preview server instead of importing module
- const previewServer = await startPreviewServer(serverEnv.config)
- const baseUrl = getResolvedUrl(previewServer)
+ let cleanup: () => Promise
+ let baseUrl: URL
+
+ if (nitroServerPath) {
+ // Start Nitro server as a subprocess
+ const { url, close } = await startNitroServer(nitroServerPath)
+ baseUrl = url
+ cleanup = close
+ } else {
+ // Start Vite preview server
+ const configFile =
+ configFileOverride ??
+ builder?.environments[VITE_ENVIRONMENT_NAMES.server]?.config.configFile
+ const previewServer = await startPreviewServer(configFile)
+ baseUrl = getResolvedUrl(previewServer)
+ cleanup = async () => {
+ await previewServer.close()
+ }
+ }
const isRedirectResponse = (res: Response) => {
return res.status >= 300 && res.status < 400 && res.headers.get('location')
}
+
async function localFetch(
- path: string,
+ fetchPath: string,
options?: RequestInit,
maxRedirects: number = 5,
): Promise {
- const url = new URL(path, baseUrl)
+ const url = new URL(fetchPath, baseUrl)
const request = new Request(url, options)
const response = await fetch(request)
@@ -98,8 +123,9 @@ export async function prerender({
})
} catch (error) {
logger.error(error)
+ throw error
} finally {
- await previewServer.close()
+ await cleanup()
}
function extractLinks(html: string): Array {
@@ -166,9 +192,7 @@ export async function prerender({
const res = await localFetch(
withBase(page.path, routerBasePath),
{
- headers: {
- ...(prerenderOptions.headers ?? {}),
- },
+ headers: prerenderOptions.headers,
},
prerenderOptions.maxRedirects,
)
@@ -262,19 +286,81 @@ export async function prerender({
}
}
+async function startNitroServer(
+ serverPath: string,
+): Promise<{ url: URL; close: () => Promise }> {
+ return new Promise((resolve, reject) => {
+ // Find a random port
+ const port = 3000 + Math.floor(Math.random() * 10000)
+ const env = { ...process.env, PORT: String(port) }
+
+ const child: ChildProcess = spawn('node', [serverPath], {
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+
+ let resolved = false
+ const timeout = setTimeout(() => {
+ if (!resolved) {
+ child.kill()
+ reject(new Error('Nitro server startup timed out'))
+ }
+ }, 30000)
+
+ const checkServer = async () => {
+ try {
+ const res = await fetch(`http://localhost:${port}/`)
+ if (res.ok || res.status < 500) {
+ resolved = true
+ clearTimeout(timeout)
+ resolve({
+ url: new URL(`http://localhost:${port}`),
+ close: async () => {
+ child.kill('SIGTERM')
+ // Wait a bit for graceful shutdown
+ await new Promise((r) => setTimeout(r, 500))
+ },
+ })
+ }
+ } catch {
+ // Server not ready yet, retry
+ if (!resolved) {
+ setTimeout(checkServer, 100)
+ }
+ }
+ }
+
+ child.on('error', (err) => {
+ if (!resolved) {
+ clearTimeout(timeout)
+ reject(err)
+ }
+ })
+
+ child.stderr?.on('data', (data) => {
+ console.error('[nitro]', data.toString())
+ })
+
+ // Start checking after a short delay
+ setTimeout(checkServer, 200)
+ })
+}
+
async function startPreviewServer(
- viteConfig: ResolvedConfig,
+ configFile?: string | false,
): Promise {
const vite = await import('vite')
try {
- return await vite.preview({
- configFile: viteConfig.configFile,
+ const server = await vite.preview({
+ configFile,
preview: {
port: 0,
open: false,
},
})
+
+ return server
} catch (error) {
throw new Error(
'Failed to start the Vite preview server for prerendering',
diff --git a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts
index 973b2b192b6..eed8ce4628a 100644
--- a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts
@@ -193,8 +193,6 @@ export function startCompilerPlugin(
}
let root = process.cwd()
- let command: 'build' | 'serve' = 'build'
-
const resolvedResolverVirtualImportId = resolveViteId(
VIRTUAL_MODULES.serverFnResolver,
)
@@ -227,7 +225,6 @@ export function startCompilerPlugin(
},
configResolved(config) {
root = config.root
- command = config.command
},
transform: {
filter: {
@@ -373,7 +370,6 @@ export function startCompilerPlugin(
},
configResolved(config) {
root = config.root
- command = config.command
},
resolveId: {
filter: { id: new RegExp(VIRTUAL_MODULES.serverFnResolver) },
diff --git a/packages/start-plugin-core/src/types.ts b/packages/start-plugin-core/src/types.ts
index e2273d1a127..12d04f12c3b 100644
--- a/packages/start-plugin-core/src/types.ts
+++ b/packages/start-plugin-core/src/types.ts
@@ -23,6 +23,7 @@ export interface ResolvedStartConfig {
routerFilePath: string
srcDirectory: string
viteAppBase: string
+ viteConfigFile?: string
serverFnProviderEnv: string
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3ab9c9061b6..0f20f545fee 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1293,6 +1293,122 @@ importers:
specifier: ^4.49.1
version: 4.49.1
+ e2e/react-start/basic-nitro:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ '@tanstack/react-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/react-router-devtools
+ '@tanstack/react-start':
+ specifier: workspace:*
+ version: link:../../../packages/react-start
+ react:
+ specifier: ^19.2.0
+ version: 19.2.0
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.0(react@19.2.0)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.56.1
+ '@tailwindcss/postcss':
+ specifier: ^4.1.15
+ version: 4.1.18
+ '@tanstack/nitro-v2-vite-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/nitro-v2-vite-plugin
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/node':
+ specifier: 22.10.2
+ version: 22.10.2
+ '@types/react':
+ specifier: ^19.2.2
+ version: 19.2.2
+ '@types/react-dom':
+ specifier: ^19.2.2
+ version: 19.2.2(@types/react@19.2.2)
+ nitro:
+ specifier: npm:nitro-nightly@latest
+ version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.52.5)(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))
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.15
+ version: 4.1.18
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.2
+ vite:
+ specifier: ^7.1.7
+ version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ 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))
+
+ e2e/react-start/basic-nitro-spa:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ '@tanstack/react-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/react-router-devtools
+ '@tanstack/react-start':
+ specifier: workspace:*
+ version: link:../../../packages/react-start
+ react:
+ specifier: ^19.2.0
+ version: 19.2.0
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.0(react@19.2.0)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.56.1
+ '@tailwindcss/postcss':
+ specifier: ^4.1.15
+ version: 4.1.18
+ '@tanstack/nitro-v2-vite-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/nitro-v2-vite-plugin
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/node':
+ specifier: 22.10.2
+ version: 22.10.2
+ '@types/react':
+ specifier: ^19.2.2
+ version: 19.2.2
+ '@types/react-dom':
+ specifier: ^19.2.2
+ version: 19.2.2(@types/react@19.2.2)
+ nitro:
+ specifier: npm:nitro-nightly@latest
+ version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.52.5)(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))
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.15
+ version: 4.1.18
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.2
+ vite:
+ specifier: ^7.1.7
+ version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ 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))
+
e2e/react-start/basic-react-query:
dependencies:
'@tanstack/react-query':
@@ -3251,6 +3367,110 @@ importers:
specifier: ^4.49.1
version: 4.49.1
+ e2e/solid-start/basic-nitro:
+ dependencies:
+ '@tanstack/solid-router':
+ specifier: workspace:^
+ version: link:../../../packages/solid-router
+ '@tanstack/solid-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/solid-router-devtools
+ '@tanstack/solid-start':
+ specifier: workspace:*
+ version: link:../../../packages/solid-start
+ solid-js:
+ specifier: 1.9.10
+ version: 1.9.10
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.56.1
+ '@tailwindcss/postcss':
+ specifier: ^4.1.15
+ version: 4.1.18
+ '@tanstack/nitro-v2-vite-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/nitro-v2-vite-plugin
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/node':
+ specifier: 22.10.2
+ version: 22.10.2
+ nitro:
+ specifier: npm:nitro-nightly@latest
+ version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.52.5)(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))
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.15
+ version: 4.1.18
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.2
+ vite:
+ specifier: ^7.1.7
+ version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.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.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))
+ 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))
+
+ e2e/solid-start/basic-nitro-spa:
+ dependencies:
+ '@tanstack/solid-router':
+ specifier: workspace:^
+ version: link:../../../packages/solid-router
+ '@tanstack/solid-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/solid-router-devtools
+ '@tanstack/solid-start':
+ specifier: workspace:*
+ version: link:../../../packages/solid-start
+ solid-js:
+ specifier: 1.9.10
+ version: 1.9.10
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.56.1
+ '@tailwindcss/postcss':
+ specifier: ^4.1.15
+ version: 4.1.18
+ '@tanstack/nitro-v2-vite-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/nitro-v2-vite-plugin
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/node':
+ specifier: 22.10.2
+ version: 22.10.2
+ nitro:
+ specifier: npm:nitro-nightly@latest
+ version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.52.5)(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))
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.15
+ version: 4.1.18
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.2
+ vite:
+ specifier: ^7.1.7
+ version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.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.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))
+ 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))
+
e2e/solid-start/basic-solid-query:
dependencies:
'@tanstack/solid-query':
@@ -11468,10 +11688,10 @@ importers:
version: 2.0.0
seroval:
specifier: ^1.4.1
- version: 1.4.1
+ version: 1.4.2
seroval-plugins:
specifier: ^1.4.0
- version: 1.4.0(seroval@1.4.1)
+ version: 1.4.0(seroval@1.4.2)
tiny-invariant:
specifier: ^1.3.3
version: 1.3.3
@@ -11903,7 +12123,7 @@ importers:
version: link:../start-storage-context
seroval:
specifier: ^1.4.1
- version: 1.4.1
+ version: 1.4.2
tiny-invariant:
specifier: ^1.3.3
version: 1.3.3
@@ -12005,7 +12225,7 @@ importers:
version: h3@2.0.1-rc.7(crossws@0.4.1(srvx@0.10.0))
seroval:
specifier: ^1.4.1
- version: 1.4.1
+ version: 1.4.2
tiny-invariant:
specifier: ^1.3.3
version: 1.3.3
@@ -12033,7 +12253,7 @@ importers:
version: link:../start-client-core
seroval:
specifier: ^1.4.1
- version: 1.4.1
+ version: 1.4.2
packages/start-storage-context:
dependencies:
@@ -12426,6 +12646,11 @@ packages:
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
engines: {node: '>=6.9.0'}
+ '@babel/parser@7.27.5':
+ resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/parser@7.28.5':
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
engines: {node: '>=6.0.0'}
@@ -14187,6 +14412,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@jridgewell/trace-mapping@0.3.25':
+ resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -22404,8 +22632,8 @@ packages:
resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==}
engines: {node: '>=10'}
- seroval@1.4.1:
- resolution: {integrity: sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==}
+ seroval@1.4.2:
+ resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==}
engines: {node: '>=10'}
serve-index@1.9.1:
@@ -24190,6 +24418,10 @@ snapshots:
'@babel/template': 7.27.2
'@babel/types': 7.28.5
+ '@babel/parser@7.27.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
'@babel/parser@7.28.5':
dependencies:
'@babel/types': 7.28.5
@@ -24327,7 +24559,7 @@ snapshots:
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
- '@babel/parser': 7.28.5
+ '@babel/parser': 7.27.5
'@babel/types': 7.28.5
'@babel/traverse@7.28.5':
@@ -25724,7 +25956,7 @@ snapshots:
'@img/sharp-wasm32@0.34.4':
dependencies:
- '@emnapi/runtime': 1.5.0
+ '@emnapi/runtime': 1.7.1
optional: true
'@img/sharp-win32-arm64@0.34.4':
@@ -25837,6 +26069,11 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@jridgewell/trace-mapping@0.3.25':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -26200,8 +26437,8 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
- '@emnapi/core': 1.5.0
- '@emnapi/runtime': 1.5.0
+ '@emnapi/core': 1.7.1
+ '@emnapi/runtime': 1.7.1
'@tybys/wasm-util': 0.10.1
optional: true
@@ -29129,7 +29366,7 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.28.5
+ '@babel/parser': 7.27.5
'@babel/types': 7.28.5
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
@@ -29141,7 +29378,7 @@ snapshots:
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.28.5
+ '@babel/parser': 7.27.5
'@babel/types': 7.28.5
'@types/babel__traverse@7.28.0':
@@ -29204,7 +29441,7 @@ snapshots:
'@types/eslint@9.6.1':
dependencies:
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@types/estree@1.0.7': {}
@@ -32167,7 +32404,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
esutils@2.0.3: {}
@@ -33126,7 +33363,7 @@ snapshots:
is-reference@1.2.1:
dependencies:
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
is-regex@1.2.1:
dependencies:
@@ -35380,13 +35617,13 @@ snapshots:
dependencies:
seroval: 1.3.2
- seroval-plugins@1.4.0(seroval@1.4.1):
+ seroval-plugins@1.4.0(seroval@1.4.2):
dependencies:
- seroval: 1.4.1
+ seroval: 1.4.2
seroval@1.3.2: {}
- seroval@1.4.1: {}
+ seroval@1.4.2: {}
serve-index@1.9.1:
dependencies:
@@ -35845,7 +36082,7 @@ snapshots:
terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))):
dependencies:
- '@jridgewell/trace-mapping': 0.3.31
+ '@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 4.3.0
serialize-javascript: 6.0.2
@@ -35856,7 +36093,7 @@ snapshots:
terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1):
dependencies:
- '@jridgewell/trace-mapping': 0.3.31
+ '@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 4.3.0
serialize-javascript: 6.0.2