Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion docs/start/framework/react/guide/hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,33 @@ bun run server.ts
🚀 Server running at http://localhost:3000
```

For a complete working example, check out the [TanStack Start + Bun example](https://github.com/TanStack/router/tree/main/examples/react/start-bun) in this repository.
For a complete working example of **Vite build + Bun HTTP host**, check out the [TanStack Start + Bun example](https://github.com/TanStack/router/tree/main/examples/react/start-bun) in this repository.

### Bun as the bundler (experimental)

There is also an experimental path that uses **Bun as the bundler** (no Vite), via `@tanstack/react-start/plugin/bun`:

```ts
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({ bun: { port: 3000 } })
await start.build()
// or: await start.dev()
```

Default production output matches the **Rsbuild-style** host: `dist/client` + `dist/server/server.js` + `dist/server/host.js` (static assets then `fetch`). Deploy `dist/` and run `bun dist/server/host.js`. See the [`start-bun-bundler`](https://github.com/TanStack/router/tree/main/examples/react/start-bun-bundler) example. Solid/Vue mirrors: `@tanstack/solid-start/plugin/bun`, `@tanstack/vue-start/plugin/bun`.

**Optional extras (production only, experimental):** `bun.nitro` (post-build Nitro 3 → `.output`; cannot reuse `nitro/vite`) and `bun.standalone` (`Bun.build({ compile })` single OS/arch executable embedding `dist/client`). Prefer the default `host.js` path unless you need those outputs. Details and scripts live in the React example README / [`ARCHITECTURE.md`](https://github.com/TanStack/router/blob/main/packages/start-plugin-core/src/bun/ARCHITECTURE.md).

**Dev HMR:** experimental ESM middleware + HMR + React Refresh. Entry aliases and define map are applied in the transform path; built `/assets` scripts are scrubbed from SSR HTML/manifest so they do not fight the ESM-dev client. Granularity and stability are not on par with Vite; some client changes may still trigger a full rebuild.

**Serialization adapters:** pass `serializationAdapters` through the framework Start options (same as Vite/Rsbuild). The Bun adapter wires them into `#tanstack-start-plugin-adapters` for client and server builds.

**Known limitations:**

- **No RSC** — React Server Components are not supported on the Bun bundler adapter.
- Import protection is a simplified deny/mock path (no full Vite-style graph tracing / source maps yet).
- Optional `bun.nitro` / `bun.standalone` are production-only and experimental.

### Appwrite Sites

Expand Down
48 changes: 48 additions & 0 deletions examples/react/start-bun-bundler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# TanStack Start + Bun Bundler

Minimal example that builds with **Bun as the bundler** (no Vite).

## vs `start-bun`

| | [`start-bun`](../start-bun) | **this example** |
|--|--|--|
| Dev / build | Vite (`vite dev` / `vite build`) | `tanstackStart().dev()` / `.build()` via Bun |
| Production host | `Bun.serve` + Vite `dist` | **Default:** `host.js` (`dist/server/host.js`) |
| Plugin entry | `@tanstack/react-start/plugin/vite` | `@tanstack/react-start/plugin/bun` |

## Scripts

```bash
cd examples/react/start-bun-bundler
bun run build # → dist/client + dist/server/server.js + host.js
bun run start # bun dist/server/host.js
bun run dev
bun run smoke # default path (CI)
```

### Optional extras (experimental)

```bash
bun run build:nitro # + bun.nitro → .output/
bun run start:nitro # node .output/server/index.mjs
bun run smoke:nitro
bun run build:standalone # + bun.standalone → dist/server/start
bun run start:standalone # ./dist/server/start
bun run smoke:standalone
```

Prefer the default `host.js` path unless you need Nitro `.output` or a single OS/arch executable. Standalone always embeds `dist/client` (not `.output/public`).

## What this proves

- Dual `Bun.build` without Vite
- SSR + prerender + static `host.js`
- Code-splitting, import protection, CSS pipeline, experimental ESM HMR + React Refresh (dev)

## Known limitations

- **No RSC** — React Server Components are not supported
- Dev HMR is experimental (not Vite-level); some client changes may still full-rebuild
- Nitro / standalone are optional extras (production-only, experimental)

See `packages/start-plugin-core/src/bun/ARCHITECTURE.md`.
32 changes: 32 additions & 0 deletions examples/react/start-bun-bundler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "tanstack-start-bun-bundler",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run ./scripts/dev.ts",
"build": "bun run ./scripts/build.ts",
"build:nitro": "bun run ./scripts/build-nitro.ts",
"build:standalone": "bun run ./scripts/build-standalone.ts",
"start": "bun run ./dist/server/host.js",
"start:nitro": "node .output/server/index.mjs",
"start:standalone": "./dist/server/start",
"smoke": "bun run ./scripts/smoke.ts",
"smoke:nitro": "bun run ./scripts/smoke-nitro.ts",
"smoke:standalone": "bun run ./scripts/smoke-standalone.ts",
"test:e2e": "bun run smoke"
},
"dependencies": {
"@tanstack/react-router": "workspace:*",
"@tanstack/react-start": "workspace:*",
"@tanstack/router-plugin": "workspace:*",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/bun": "^1.2.22",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"typescript": "^5.9.0"
}
}
2 changes: 2 additions & 0 deletions examples/react/start-bun-bundler/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
19 changes: 19 additions & 0 deletions examples/react/start-bun-bundler/scripts/build-nitro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({
pages: [{ path: '/' }],
prerender: {
enabled: true,
failOnError: true,
},
bun: {
nitro: {
preset: 'node-server',
},
},
})

await start.build()
console.info(
'[start-bun-bundler] nitro build complete → dist/* + .output/public + .output/server',
)
19 changes: 19 additions & 0 deletions examples/react/start-bun-bundler/scripts/build-standalone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({
pages: [{ path: '/' }],
prerender: {
enabled: true,
failOnError: true,
},
bun: {
standalone: {
outfile: 'dist/server/start',
},
},
})

await start.build()
console.info(
'[start-bun-bundler] standalone build complete → dist/server/start',
)
12 changes: 12 additions & 0 deletions examples/react/start-bun-bundler/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({
pages: [{ path: '/' }],
prerender: {
enabled: true,
failOnError: true,
},
})

await start.build()
console.info('[start-bun-bundler] build complete → dist/client + dist/server')
4 changes: 4 additions & 0 deletions examples/react/start-bun-bundler/scripts/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({ bun: { port: 3000 } })
await start.dev()
108 changes: 108 additions & 0 deletions examples/react/start-bun-bundler/scripts/smoke-nitro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Smoke check: Nitro bridge build → .output/server → assert `/`, assets, public dir.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'

const root = join(import.meta.dir, '..')
const DEFAULT_SMOKE_PORT = 3460
const parsedPort = Number(process.env.SMOKE_PORT ?? DEFAULT_SMOKE_PORT)
const port =
Number.isFinite(parsedPort) && parsedPort > 0
? Math.trunc(parsedPort)
: DEFAULT_SMOKE_PORT
const host = '127.0.0.1'

async function waitForServer(url: string, attempts = 60) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url)
if (res.ok || res.status === 200) {
return
}
} catch {
// retry
}
await Bun.sleep(150)
}
throw new Error(`Server did not become ready at ${url}`)
}

console.info('[smoke-nitro] building with bun.nitro…')
const build = spawn('bun', ['run', './scripts/build-nitro.ts'], {
cwd: root,
stdio: 'inherit',
})
await new Promise<void>((resolve, reject) => {
build.on('error', reject)
build.on('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`build-nitro exited ${code}`)),
)
})

const publicDir = join(root, '.output/public')
const serverEntry = join(root, '.output/server/index.mjs')
if (!existsSync(publicDir)) {
throw new Error(`missing ${publicDir}`)
}
if (!existsSync(serverEntry)) {
throw new Error(`missing ${serverEntry}`)
}

const assetFiles = [...new Bun.Glob('assets/**/*').scanSync({ cwd: publicDir })]
if (assetFiles.length === 0) {
throw new Error(`.output/public has no assets/ files`)
}

console.info('[smoke-nitro] starting .output/server/index.mjs…')
const server = spawn('node', [serverEntry], {
cwd: root,
env: { ...process.env, PORT: String(port), NITRO_PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe'],
})

let stdout = ''
let stderr = ''
server.stdout?.on('data', (chunk) => {
stdout += String(chunk)
})
server.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})

try {
await waitForServer(`http://${host}:${port}/`)

const home = await fetch(`http://${host}:${port}/`)
const homeHtml = await home.text()
if (!home.ok) {
throw new Error(`GET / → ${home.status}`)
}
if (!homeHtml.includes('Hello from Bun-bundled Start')) {
throw new Error('GET / missing loader message in HTML')
}

const preloadMatch = homeHtml.match(
/modulepreload[^>]+href="(\/assets\/[^"]+\.js)"/,
)
if (!preloadMatch?.[1]) {
throw new Error('GET / missing modulepreload asset href')
}
const asset = await fetch(`http://${host}:${port}${preloadMatch[1]}`)
if (!asset.ok) {
throw new Error(`GET ${preloadMatch[1]} → ${asset.status}`)
}

console.info('[smoke-nitro] ok')
} catch (err) {
if (stdout) {
console.error('[smoke-nitro] server stdout:\n', stdout)
}
if (stderr) {
console.error('[smoke-nitro] server stderr:\n', stderr)
}
throw err
} finally {
server.kill('SIGTERM')
}
101 changes: 101 additions & 0 deletions examples/react/start-bun-bundler/scripts/smoke-standalone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Smoke: bun.standalone compile → run dist/server/start → assert `/` + assets.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'

const root = join(import.meta.dir, '..')
const DEFAULT_SMOKE_PORT = 3461
const parsedPort = Number(process.env.SMOKE_PORT ?? DEFAULT_SMOKE_PORT)
const port =
Number.isFinite(parsedPort) && parsedPort > 0
? Math.trunc(parsedPort)
: DEFAULT_SMOKE_PORT
const host = '127.0.0.1'
const exe = join(root, 'dist/server/start')

async function waitForServer(url: string, attempts = 80) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url)
if (res.ok || res.status === 200) {
return
}
} catch {
// retry
}
await Bun.sleep(150)
}
throw new Error(`Server did not become ready at ${url}`)
}

console.info('[smoke-standalone] building with bun.standalone…')
const build = spawn('bun', ['run', './scripts/build-standalone.ts'], {
cwd: root,
stdio: 'inherit',
})
await new Promise<void>((resolve, reject) => {
build.on('error', reject)
build.on('exit', (code) =>
code === 0
? resolve()
: reject(new Error(`build-standalone exited ${code}`)),
)
})

if (!existsSync(exe)) {
throw new Error(`missing standalone executable at ${exe}`)
}

console.info('[smoke-standalone] starting executable…')
const server = spawn(exe, [], {
cwd: root,
env: { ...process.env, PORT: String(port), HOST: host },
stdio: ['ignore', 'pipe', 'pipe'],
})

let stdout = ''
let stderr = ''
server.stdout?.on('data', (chunk) => {
stdout += String(chunk)
})
server.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})

try {
await waitForServer(`http://${host}:${port}/`)

const home = await fetch(`http://${host}:${port}/`)
const homeHtml = await home.text()
if (!home.ok) {
throw new Error(`GET / → ${home.status}`)
}
if (!homeHtml.includes('Hello from Bun-bundled Start')) {
throw new Error('GET / missing loader message in HTML')
}

const preloadMatch = homeHtml.match(
/modulepreload[^>]+href="(\/assets\/[^"]+\.js)"/,
)
if (!preloadMatch?.[1]) {
throw new Error('GET / missing modulepreload asset href')
}
const asset = await fetch(`http://${host}:${port}${preloadMatch[1]}`)
if (!asset.ok) {
throw new Error(`GET ${preloadMatch[1]} → ${asset.status}`)
}

console.info('[smoke-standalone] ok')
} catch (err) {
if (stdout) {
console.error('[smoke-standalone] server stdout:\n', stdout)
}
if (stderr) {
console.error('[smoke-standalone] server stderr:\n', stderr)
}
throw err
} finally {
server.kill('SIGTERM')
}
Loading