From ec4a73e8afc5f3c70805968ce443bfce5ad9443d Mon Sep 17 00:00:00 2001
From: Birk Skyum <birk.skyum@pm.me>
Date: Sun, 26 Oct 2025 02:30:27 +0200
Subject: [PATCH 1/4] prerendering - sync #5476 to solid-start

---
 e2e/solid-start/basic/package.json            |  6 ++-
 e2e/solid-start/basic/playwright.config.ts    | 17 ++++--
 .../basic/tests/prerendering.spec.ts          | 53 +++++++++++++++++++
 .../basic/tests/search-params.spec.ts         |  9 ++--
 .../basic/tests/utils/isPrerender.ts          |  1 +
 e2e/solid-start/basic/vite.config.ts          | 15 ++++++
 6 files changed, 93 insertions(+), 8 deletions(-)
 create mode 100644 e2e/solid-start/basic/tests/prerendering.spec.ts
 create mode 100644 e2e/solid-start/basic/tests/utils/isPrerender.ts

diff --git a/e2e/solid-start/basic/package.json b/e2e/solid-start/basic/package.json
index b71b1238dc..d77a05fe39 100644
--- a/e2e/solid-start/basic/package.json
+++ b/e2e/solid-start/basic/package.json
@@ -8,11 +8,15 @@
     "dev:e2e": "vite dev",
     "build": "vite build && tsc --noEmit",
     "build:spa": "MODE=spa vite build && tsc --noEmit",
+    "build:prerender": "MODE=prerender vite build && tsc --noEmit",
     "start": "pnpx srvx --prod -s ../client  dist/server/server.js",
     "start:spa": "node server.js",
+    "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &",
+    "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'",
     "test:e2e:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium",
     "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium",
-    "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode"
+    "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium",
+    "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender"
   },
   "dependencies": {
     "@tanstack/solid-router": "workspace:^",
diff --git a/e2e/solid-start/basic/playwright.config.ts b/e2e/solid-start/basic/playwright.config.ts
index 786244ff4c..493f4e29ed 100644
--- a/e2e/solid-start/basic/playwright.config.ts
+++ b/e2e/solid-start/basic/playwright.config.ts
@@ -3,8 +3,9 @@ import {
   getDummyServerPort,
   getTestServerPort,
 } from '@tanstack/router-e2e-utils'
-import packageJson from './package.json' with { type: 'json' }
 import { isSpaMode } from './tests/utils/isSpaMode'
+import { isPrerender } from './tests/utils/isPrerender'
+import packageJson from './package.json' with { type: 'json' }
 
 const PORT = await getTestServerPort(
   `${packageJson.name}${isSpaMode ? '_spa' : ''}`,
@@ -16,15 +17,21 @@ const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
 const baseURL = `http://localhost:${PORT}`
 const spaModeCommand = `pnpm build:spa && pnpm start:spa`
 const ssrModeCommand = `pnpm build && pnpm start`
+const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start`
 
+const getCommand = () => {
+  if (isSpaMode) return spaModeCommand
+  if (isPrerender) return prerenderModeCommand
+  return ssrModeCommand
+}
 console.log('running in spa mode: ', isSpaMode.toString())
+console.log('running in prerender mode: ', isPrerender.toString())
 /**
  * See https://playwright.dev/docs/test-configuration.
  */
 export default defineConfig({
   testDir: './tests',
   workers: 1,
-
   reporter: [['line']],
 
   globalSetup: './tests/setup/global.setup.ts',
@@ -36,7 +43,7 @@ export default defineConfig({
   },
 
   webServer: {
-    command: isSpaMode ? spaModeCommand : ssrModeCommand,
+    command: getCommand(),
     url: baseURL,
     reuseExistingServer: !process.env.CI,
     stdout: 'pipe',
@@ -53,7 +60,9 @@ export default defineConfig({
   projects: [
     {
       name: 'chromium',
-      use: { ...devices['Desktop Chrome'] },
+      use: {
+        ...devices['Desktop Chrome'],
+      },
     },
   ],
 })
diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts
new file mode 100644
index 0000000000..9a3ec145dc
--- /dev/null
+++ b/e2e/solid-start/basic/tests/prerendering.spec.ts
@@ -0,0 +1,53 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import { isPrerender } from './utils/isPrerender'
+
+test.describe('Prerender Static Path Discovery', () => {
+  test.skip(!isPrerender, 'Skipping since not in prerender mode')
+  test.describe('Build Output Verification', () => {
+    test('should automatically discover and prerender static routes', () => {
+      // Check that static routes were automatically discovered and prerendered
+      const distDir = join(process.cwd(), 'dist', 'client')
+
+      // These static routes should be automatically discovered and prerendered
+      expect(existsSync(join(distDir, 'index.html'))).toBe(true)
+      expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true)
+      expect(existsSync(join(distDir, 'users/index.html'))).toBe(true)
+      expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true)
+      expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true)
+      expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true)
+      expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true)
+
+      // Pathless layouts should NOT be prerendered (they start with _)
+      expect(existsSync(join(distDir, '_layout', 'index.html'))).toBe(false) // /_layout
+
+      // API routes should NOT be prerendered
+
+      expect(existsSync(join(distDir, 'api', 'users', 'index.html'))).toBe(
+        false,
+      ) // /api/users
+    })
+  })
+
+  test.describe('Static Files Verification', () => {
+    test('should contain prerendered content in posts.html', () => {
+      const distDir = join(process.cwd(), 'dist', 'client')
+      expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true)
+
+      // "Select a post." should be in the prerendered HTML
+      const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8')
+      expect(html).toContain('Select a post.')
+    })
+
+    test('should contain prerendered content in users.html', () => {
+      const distDir = join(process.cwd(), 'dist', 'client')
+      expect(existsSync(join(distDir, 'users/index.html'))).toBe(true)
+
+      // "Select a user." should be in the prerendered HTML
+      const html = readFileSync(join(distDir, 'users/index.html'), 'utf-8')
+      expect(html).toContain('Select a user.')
+    })
+  })
+})
diff --git a/e2e/solid-start/basic/tests/search-params.spec.ts b/e2e/solid-start/basic/tests/search-params.spec.ts
index 64ef39919e..74c1aa54dc 100644
--- a/e2e/solid-start/basic/tests/search-params.spec.ts
+++ b/e2e/solid-start/basic/tests/search-params.spec.ts
@@ -1,6 +1,7 @@
 import { expect } from '@playwright/test'
 import { test } from '@tanstack/router-e2e-utils'
-import { isSpaMode } from '../tests/utils/isSpaMode'
+import { isSpaMode } from 'tests/utils/isSpaMode'
+import { isPrerender } from './utils/isPrerender'
 import type { Response } from '@playwright/test'
 
 function expectRedirect(response: Response | null, endsWith: string) {
@@ -26,9 +27,11 @@ test.describe('/search-params/loader-throws-redirect', () => {
     page,
   }) => {
     const response = await page.goto('/search-params/loader-throws-redirect')
-    if (!isSpaMode) {
+
+    if (!isSpaMode && !isPrerender) {
       expectRedirect(response, '/search-params/loader-throws-redirect?step=a')
     }
+
     await expect(page.getByTestId('search-param')).toContainText('a')
     expect(page.url().endsWith('/search-params/loader-throws-redirect?step=a'))
   })
@@ -50,7 +53,7 @@ test.describe('/search-params/default', () => {
     page,
   }) => {
     const response = await page.goto('/search-params/default')
-    if (!isSpaMode) {
+    if (!isSpaMode && !isPrerender) {
       expectRedirect(response, '/search-params/default?default=d1')
     }
     await expect(page.getByTestId('search-default')).toContainText('d1')
diff --git a/e2e/solid-start/basic/tests/utils/isPrerender.ts b/e2e/solid-start/basic/tests/utils/isPrerender.ts
new file mode 100644
index 0000000000..d5d991d454
--- /dev/null
+++ b/e2e/solid-start/basic/tests/utils/isPrerender.ts
@@ -0,0 +1 @@
+export const isPrerender: boolean = process.env.MODE === 'prerender'
diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts
index 60c1947e40..f4fe143b6f 100644
--- a/e2e/solid-start/basic/vite.config.ts
+++ b/e2e/solid-start/basic/vite.config.ts
@@ -3,6 +3,7 @@ import tsConfigPaths from 'vite-tsconfig-paths'
 import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
 import viteSolid from 'vite-plugin-solid'
 import { isSpaMode } from './tests/utils/isSpaMode'
+import { isPrerender } from './tests/utils/isPrerender'
 
 const spaModeConfiguration = {
   enabled: true,
@@ -11,6 +12,19 @@ const spaModeConfiguration = {
   },
 }
 
+const prerenderConfiguration = {
+  enabled: true,
+  filter: (page: { path: string }) =>
+    ![
+      '/this-route-does-not-exist',
+      '/redirect',
+      '/i-do-not-exist',
+      '/not-found/via-beforeLoad',
+      '/not-found/via-loader',
+    ].some((p) => page.path.includes(p)),
+  maxRedirects: 100,
+}
+
 export default defineConfig({
   server: {
     port: 3000,
@@ -22,6 +36,7 @@ export default defineConfig({
     // @ts-ignore we want to keep one test with verboseFileRoutes off even though the option is hidden
     tanstackStart({
       spa: isSpaMode ? spaModeConfiguration : undefined,
+      prerender: isPrerender ? prerenderConfiguration : undefined,
     }),
     viteSolid({ ssr: true }),
   ],

From cc55fd47906b950be205033970b8153fec76e6f7 Mon Sep 17 00:00:00 2001
From: Birk Skyum <birk.skyum@pm.me>
Date: Mon, 27 Oct 2025 02:23:41 +0100
Subject: [PATCH 2/4] prerender fix

---
 packages/start-plugin-core/src/prerender.ts | 30 +++++++++++++++++----
 1 file changed, 25 insertions(+), 5 deletions(-)

diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts
index 8c22633b37..aba68b7ecd 100644
--- a/packages/start-plugin-core/src/prerender.ts
+++ b/packages/start-plugin-core/src/prerender.ts
@@ -89,7 +89,8 @@ export async function prerender({
     path: string,
     options?: RequestInit,
     maxRedirects: number = 5,
-  ): Promise<Response> {
+    wasRedirected: boolean = false,
+  ): Promise<{ response: Response; wasRedirected: boolean; finalPath: string }> {
     const url = new URL(`http://localhost${path}`)
     const response = await serverEntrypoint.fetch(new Request(url, options))
 
@@ -97,13 +98,17 @@ export async function prerender({
       const location = response.headers.get('location')!
       if (location.startsWith('http://localhost') || location.startsWith('/')) {
         const newUrl = location.replace('http://localhost', '')
-        return localFetch(newUrl, options, maxRedirects - 1)
+        return localFetch(newUrl, options, maxRedirects - 1, true)
       } else {
         logger.warn(`Skipping redirect to external location: ${location}`)
       }
     }
 
-    return response
+    return {
+      response,
+      wasRedirected,
+      finalPath: path,
+    }
   }
 
   try {
@@ -136,6 +141,7 @@ export async function prerender({
   async function prerenderPages({ outputDir }: { outputDir: string }) {
     const seen = new Set<string>()
     const prerendered = new Set<string>()
+    const writtenFiles = new Set<string>()
     const retriesByPath = new Map<string, number>()
     const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length
     logger.info(`Concurrency: ${concurrency}`)
@@ -180,7 +186,7 @@ export async function prerender({
           // Fetch the route
           const encodedRoute = encodeURI(page.path)
 
-          const res = await localFetch(
+          const { response: res, wasRedirected, finalPath } = await localFetch(
             withBase(encodedRoute, routerBasePath),
             {
               headers: {
@@ -199,8 +205,14 @@ export async function prerender({
             })
           }
 
+          // Use the final path (after redirects) for determining output location
+          const pathForOutput = wasRedirected ? finalPath : page.path
+          if (wasRedirected) {
+            logger.info(`Page ${page.path} redirected to ${finalPath}, using final path for output`)
+          }
+
           const cleanPagePath = (
-            prerenderOptions.outputPath || page.path
+            prerenderOptions.outputPath || pathForOutput
           ).split(/[?#]/)[0]!
 
           // Guess route type and populate fileName
@@ -239,12 +251,20 @@ export async function prerender({
 
           const filepath = path.join(outputDir, filename)
 
+          // Skip writing if this file path has already been written
+          // This prevents URLs with different query params from overwriting each other
+          if (writtenFiles.has(filename)) {
+            logger.info(`Skipping write for ${page.path} - file ${filename} already written`)
+            return
+          }
+
           await fsp.mkdir(path.dirname(filepath), {
             recursive: true,
           })
 
           await fsp.writeFile(filepath, html)
 
+          writtenFiles.add(filename)
           prerendered.add(page.path)
 
           const newPage = await prerenderOptions.onSuccess?.({ page, html })

From 9dbae04f1b8e3e7e7e3746ec11c7562afeba9c6e Mon Sep 17 00:00:00 2001
From: Birk Skyum <birk.skyum@pm.me>
Date: Mon, 27 Oct 2025 23:50:35 +0100
Subject: [PATCH 3/4] reset prerender

---
 packages/start-plugin-core/src/prerender.ts | 32 ++++-----------------
 1 file changed, 6 insertions(+), 26 deletions(-)

diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts
index aba68b7ecd..fdf25b942b 100644
--- a/packages/start-plugin-core/src/prerender.ts
+++ b/packages/start-plugin-core/src/prerender.ts
@@ -89,8 +89,7 @@ export async function prerender({
     path: string,
     options?: RequestInit,
     maxRedirects: number = 5,
-    wasRedirected: boolean = false,
-  ): Promise<{ response: Response; wasRedirected: boolean; finalPath: string }> {
+  ): Promise<Response> {
     const url = new URL(`http://localhost${path}`)
     const response = await serverEntrypoint.fetch(new Request(url, options))
 
@@ -98,17 +97,13 @@ export async function prerender({
       const location = response.headers.get('location')!
       if (location.startsWith('http://localhost') || location.startsWith('/')) {
         const newUrl = location.replace('http://localhost', '')
-        return localFetch(newUrl, options, maxRedirects - 1, true)
+        return localFetch(newUrl, options, maxRedirects - 1)
       } else {
         logger.warn(`Skipping redirect to external location: ${location}`)
       }
     }
 
-    return {
-      response,
-      wasRedirected,
-      finalPath: path,
-    }
+    return response
   }
 
   try {
@@ -141,7 +136,6 @@ export async function prerender({
   async function prerenderPages({ outputDir }: { outputDir: string }) {
     const seen = new Set<string>()
     const prerendered = new Set<string>()
-    const writtenFiles = new Set<string>()
     const retriesByPath = new Map<string, number>()
     const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length
     logger.info(`Concurrency: ${concurrency}`)
@@ -186,7 +180,7 @@ export async function prerender({
           // Fetch the route
           const encodedRoute = encodeURI(page.path)
 
-          const { response: res, wasRedirected, finalPath } = await localFetch(
+          const res = await localFetch(
             withBase(encodedRoute, routerBasePath),
             {
               headers: {
@@ -205,14 +199,8 @@ export async function prerender({
             })
           }
 
-          // Use the final path (after redirects) for determining output location
-          const pathForOutput = wasRedirected ? finalPath : page.path
-          if (wasRedirected) {
-            logger.info(`Page ${page.path} redirected to ${finalPath}, using final path for output`)
-          }
-
           const cleanPagePath = (
-            prerenderOptions.outputPath || pathForOutput
+            prerenderOptions.outputPath || page.path
           ).split(/[?#]/)[0]!
 
           // Guess route type and populate fileName
@@ -251,20 +239,12 @@ export async function prerender({
 
           const filepath = path.join(outputDir, filename)
 
-          // Skip writing if this file path has already been written
-          // This prevents URLs with different query params from overwriting each other
-          if (writtenFiles.has(filename)) {
-            logger.info(`Skipping write for ${page.path} - file ${filename} already written`)
-            return
-          }
-
           await fsp.mkdir(path.dirname(filepath), {
             recursive: true,
           })
 
           await fsp.writeFile(filepath, html)
 
-          writtenFiles.add(filename)
           prerendered.add(page.path)
 
           const newPage = await prerenderOptions.onSuccess?.({ page, html })
@@ -341,4 +321,4 @@ export async function writeBundleToDisk({
 
     await fsp.writeFile(fullPath, content)
   }
-}
+}
\ No newline at end of file

From d7c32dbafea44e5fcc3a869874e7f6ca1d709bea Mon Sep 17 00:00:00 2001
From: Brenley Dueck <brenleydueck@gmail.com>
Date: Sun, 2 Nov 2025 20:55:35 -0600
Subject: [PATCH 4/4] Fix prerendering

---
 e2e/solid-start/basic/vite.config.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts
index f4fe143b6f..9f4a744b19 100644
--- a/e2e/solid-start/basic/vite.config.ts
+++ b/e2e/solid-start/basic/vite.config.ts
@@ -21,6 +21,7 @@ const prerenderConfiguration = {
       '/i-do-not-exist',
       '/not-found/via-beforeLoad',
       '/not-found/via-loader',
+      '/search-params/default',
     ].some((p) => page.path.includes(p)),
   maxRedirects: 100,
 }
