Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 52 additions & 0 deletions docs/start/framework/react/guide/server-entry-point.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,58 @@ export default createServerEntry({
})
```

## Per-request SSR options

The default server entry handler accepts request options as its second argument.
Use this when you want to keep Start's default handler but override one setting
for a specific request.

```tsx
// src/server.ts
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'

export default createServerEntry({
fetch(request) {
return handler.fetch(request, {
ssr: {
streaming: {
render: request.headers.get('x-render-stream') !== 'false',
},
},
})
},
})
```

`ssr.streaming` in request options is a partial override. For example,
`{ render: false }` disables render streaming for that request.

If you need full handler-level control, create the Start handler yourself:

```tsx
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'

const handler = createStartHandler({
handler: defaultStreamHandler,
ssr: {
streaming: ({ request }) => {
if (request.headers.get('user-agent')?.includes('Googlebot')) {
return { render: false }
}

return { render: true }
},
},
})

export default createServerEntry({ fetch: handler })
```

## Server Configuration

The server entry point is where you can configure server-specific behavior:
Expand Down
1 change: 1 addition & 0 deletions e2e/react-start/streaming-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"srvx": "^0.11.9",
"typescript": "^6.0.2",
"vite": "^8.0.14"
Expand Down
6 changes: 4 additions & 2 deletions e2e/react-start/streaming-ssr/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ const PORT = await getTestServerPort(
`${packageJson.name}${isPreview ? '_preview' : ''}`,
)
const baseURL = `http://localhost:${PORT}`
const streamingEntryForm =
process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler'

const ssrCommand = `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`
const previewCommand = `VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}`
const ssrCommand = `STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} VITE_SERVER_PORT=${PORT} pnpm build && STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`
const previewCommand = `STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} VITE_SERVER_PORT=${PORT} pnpm build && STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} pnpm preview --port ${PORT}`

/**
* See https://playwright.dev/docs/test-configuration.
Expand Down
21 changes: 21 additions & 0 deletions e2e/react-start/streaming-ssr/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Route as rootRouteImport } from './routes/__root'
import { Route as SyncOnlyRouteImport } from './routes/sync-only'
import { Route as StreamingPolicyRouteImport } from './routes/streaming-policy'
import { Route as StreamRouteImport } from './routes/stream'
import { Route as SlowRenderRouteImport } from './routes/slow-render'
import { Route as QueryHeavyRouteImport } from './routes/query-heavy'
Expand All @@ -26,6 +27,11 @@ const SyncOnlyRoute = SyncOnlyRouteImport.update({
path: '/sync-only',
getParentRoute: () => rootRouteImport,
} as any)
const StreamingPolicyRoute = StreamingPolicyRouteImport.update({
id: '/streaming-policy',
path: '/streaming-policy',
getParentRoute: () => rootRouteImport,
} as any)
const StreamRoute = StreamRouteImport.update({
id: '/stream',
path: '/stream',
Expand Down Expand Up @@ -88,6 +94,7 @@ export interface FileRoutesByFullPath {
'/query-heavy': typeof QueryHeavyRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
'/streaming-policy': typeof StreamingPolicyRoute
'/sync-only': typeof SyncOnlyRoute
}
export interface FileRoutesByTo {
Expand All @@ -101,6 +108,7 @@ export interface FileRoutesByTo {
'/query-heavy': typeof QueryHeavyRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
'/streaming-policy': typeof StreamingPolicyRoute
'/sync-only': typeof SyncOnlyRoute
}
export interface FileRoutesById {
Expand All @@ -115,6 +123,7 @@ export interface FileRoutesById {
'/query-heavy': typeof QueryHeavyRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
'/streaming-policy': typeof StreamingPolicyRoute
'/sync-only': typeof SyncOnlyRoute
}
export interface FileRouteTypes {
Expand All @@ -130,6 +139,7 @@ export interface FileRouteTypes {
| '/query-heavy'
| '/slow-render'
| '/stream'
| '/streaming-policy'
| '/sync-only'
fileRoutesByTo: FileRoutesByTo
to:
Expand All @@ -143,6 +153,7 @@ export interface FileRouteTypes {
| '/query-heavy'
| '/slow-render'
| '/stream'
| '/streaming-policy'
| '/sync-only'
id:
| '__root__'
Expand All @@ -156,6 +167,7 @@ export interface FileRouteTypes {
| '/query-heavy'
| '/slow-render'
| '/stream'
| '/streaming-policy'
| '/sync-only'
fileRoutesById: FileRoutesById
}
Expand All @@ -170,6 +182,7 @@ export interface RootRouteChildren {
QueryHeavyRoute: typeof QueryHeavyRoute
SlowRenderRoute: typeof SlowRenderRoute
StreamRoute: typeof StreamRoute
StreamingPolicyRoute: typeof StreamingPolicyRoute
SyncOnlyRoute: typeof SyncOnlyRoute
}

Expand All @@ -182,6 +195,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SyncOnlyRouteImport
parentRoute: typeof rootRouteImport
}
'/streaming-policy': {
id: '/streaming-policy'
path: '/streaming-policy'
fullPath: '/streaming-policy'
preLoaderRoute: typeof StreamingPolicyRouteImport
parentRoute: typeof rootRouteImport
}
'/stream': {
id: '/stream'
path: '/stream'
Expand Down Expand Up @@ -266,6 +286,7 @@ const rootRouteChildren: RootRouteChildren = {
QueryHeavyRoute: QueryHeavyRoute,
SlowRenderRoute: SlowRenderRoute,
StreamRoute: StreamRoute,
StreamingPolicyRoute: StreamingPolicyRoute,
SyncOnlyRoute: SyncOnlyRoute,
}
export const routeTree = rootRouteImport
Expand Down
6 changes: 6 additions & 0 deletions e2e/react-start/streaming-ssr/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ function Index() {
</Link>{' '}
- Tests nested components with deferred data
</li>
<li>
<Link to="/streaming-policy" data-testid="link-streaming-policy">
Streaming Policy
</Link>{' '}
- Tests request-controlled SSR streaming policy
</li>
</ul>
</div>
)
Expand Down
41 changes: 41 additions & 0 deletions e2e/react-start/streaming-ssr/src/routes/streaming-policy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Await, createFileRoute, useRouter } from '@tanstack/react-router'
import { Suspense } from 'react'

export const Route = createFileRoute('/streaming-policy')({
loader: () => {
return {
immediate: 'Immediate policy content',
deferred: new Promise<string>((resolve) => {
setTimeout(() => resolve('Deferred policy content'), 100)
}),
}
},
component: StreamingPolicyRoute,
})

function StreamingPolicyRoute() {
const router = useRouter()
const loaderData = Route.useLoaderData()
const serverSsr = router.serverSsr
const render = serverSsr?.shouldStream('render')
const head = serverSsr?.shouldStream('head')

return (
<div style={{ padding: '20px' }}>
<h2>Streaming Policy Test</h2>
<div data-testid="policy-render" suppressHydrationWarning>
{String(render)}
</div>
<div data-testid="policy-head" suppressHydrationWarning>
{String(head)}
</div>
<div data-testid="policy-immediate">{loaderData.immediate}</div>
<Suspense fallback={<div>Loading policy content...</div>}>
<Await
promise={loaderData.deferred}
children={(value) => <div data-testid="policy-deferred">{value}</div>}
/>
</Suspense>
</div>
)
}
67 changes: 67 additions & 0 deletions e2e/react-start/streaming-ssr/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import defaultServerEntry, {
createServerEntry,
} from '@tanstack/react-start/server-entry'
import type { SsrStreamingResolverResult } from '@tanstack/react-start/server'

type SsrStreamingOverride = {
render?: boolean
head?: boolean
}

function getStreamingPolicy(request: Request): SsrStreamingResolverResult {
const url = new URL(request.url)

switch (url.searchParams.get('streaming')) {
case 'all':
return {
render: true,
head: true,
}
case 'none':
return {
render: false,
}
case 'render-only':
return {
render: true,
}
case 'head-only':
return {
render: false,
head: true,
}
default:
return undefined
}
}

function getStreamingOverride(
request: Request,
): SsrStreamingOverride | undefined {
return getStreamingPolicy(request)
}

const handler = createStartHandler({
handler: defaultStreamHandler,
ssr: {
streaming: ({ request }) => getStreamingPolicy(request),
},
})

export default createServerEntry({
fetch(request) {
if (process.env.STREAMING_SSR_ENTRY_FORM === 'default-entry') {
return defaultServerEntry.fetch(request, {
ssr: {
streaming: getStreamingOverride(request),
},
} as any)
Comment on lines +58 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Find the server-entry implementation and relevant option types =="
fd -i 'server-entry*' packages e2e | xargs -r rg -n -C3 'createServerEntry|defaultServerEntry|fetch\s*\(|streaming'

echo
echo "== Find request option / SSR streaming type definitions =="
rg -n -C3 'RequestOptions|SsrStreamingResolverResult|SsrStreamingOption|streaming\??:' \
  packages/start-server-core \
  packages/router-core \
  e2e/react-start/streaming-ssr/src/server.ts

Repository: TanStack/router

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate defaultServerEntry and its fetch signature =="
rg -n -C4 'defaultServerEntry|fetch\s*\(' e2e packages --glob '!**/dist/**' --glob '!**/build/**'

echo
echo "== Locate SSR streaming option types =="
rg -n -C4 'streaming\??:|SsrStreaming|RequestOptions|request options|server entry' e2e packages --glob '!**/dist/**' --glob '!**/build/**'

echo
echo "== Inspect the target file =="
sed -n '1,120p' e2e/react-start/streaming-ssr/src/server.ts

Repository: TanStack/router

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Target file with line numbers =="
sed -n '1,120p' e2e/react-start/streaming-ssr/src/server.ts | cat -n

echo
echo "== Request handler signature =="
sed -n '1,220p' packages/start-server-core/src/request-handler.ts | cat -n

echo
echo "== Default server entry implementation =="
sed -n '1,120p' packages/solid-start/src/default-entry/server.ts | cat -n
sed -n '1,120p' packages/vue-start/src/default-entry/server.ts | cat -n

echo
echo "== Any request-options/SSR option types =="
rg -n -C3 'type .*Options|interface .*Options|ssr:|streaming:|RequestHandler<|createServerEntry' packages/start-server-core packages/solid-start packages/vue-start --glob '!**/dist/**' --glob '!**/build/**'

Repository: TanStack/router

Length of output: 29440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== React start server-entry implementation =="
fd -i 'server.ts' packages/react-start e2e/react-start | xargs -r rg -n -C3 'createServerEntry|RequestHandler|defaultServerEntry|fetch\s*\('

echo
echo "== React start package exports =="
rg -n -C3 'server-entry|default-entry|RequestHandler|createServerEntry' packages/react-start packages/start-server-core --glob '!**/dist/**' --glob '!**/build/**'

echo
echo "== Target file exact context =="
sed -n '52,66p' e2e/react-start/streaming-ssr/src/server.ts | cat -n

Repository: TanStack/router

Length of output: 22769


Remove the as any cast on defaultServerEntry.fetch. RequestOptions<TRegister> already exposes ssr.streaming, so this just silences the type check at the call site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/react-start/streaming-ssr/src/server.ts` around lines 58 - 62, The call
to defaultServerEntry.fetch is using an unnecessary as any cast even though
RequestOptions<TRegister> already includes ssr.streaming. Remove the cast and
pass the typed options object directly in server.ts so the fetch call remains
type-safe; use defaultServerEntry.fetch and getStreamingOverride(request) as the
key symbols to locate the change.

Source: Coding guidelines

}

return handler(request)
},
})
1 change: 1 addition & 0 deletions e2e/react-start/streaming-ssr/tests/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ test.describe('Home page', () => {
await expect(
page.getByRole('link', { name: 'Stream' }).first(),
).toBeVisible()
await expect(page.getByTestId('link-streaming-policy')).toBeVisible()
},
)

Expand Down
2 changes: 2 additions & 0 deletions e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from './fixtures'

test.skip(process.env.MODE !== 'preview', 'Only runs against vite preview')

test('vite preview streams HTML incrementally', async ({ page }) => {
// /deferred has immediate data + deferred data with a ~1s delay.
// Without streaming, compression buffers the entire response.
Expand Down
49 changes: 49 additions & 0 deletions e2e/react-start/streaming-ssr/tests/streaming-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from './fixtures'

const streamingEntryForm =
process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler'

test.describe(`ssr.streaming policy (${streamingEntryForm})`, () => {
test('resolves render and head channels from the request', async ({
request,
}) => {
const cases = [
['all', 'true', 'true'],
['none', 'false', 'false'],
['render-only', 'true', 'false'],
['head-only', 'false', 'true'],
] as const

for (const [mode, render, head] of cases) {
const response = await request.get(`/streaming-policy?streaming=${mode}`)
const html = await response.text()

expect(response.ok()).toBe(true)
expect(html).toContain(`<div data-testid="policy-render">${render}</div>`)
expect(html).toContain(`<div data-testid="policy-head">${head}</div>`)
}
})

test.describe('with JavaScript disabled', () => {
test.use({ javaScriptEnabled: false })

for (const mode of ['none', 'head-only'] as const) {
test(`renders the full policy page when streaming=${mode}`, async ({
page,
}) => {
await page.goto(`/streaming-policy?streaming=${mode}`)

await expect(page.getByTestId('policy-render')).toContainText('false')
await expect(page.getByTestId('policy-head')).toContainText(
mode === 'head-only' ? 'true' : 'false',
)
await expect(page.getByTestId('policy-immediate')).toContainText(
'Immediate policy content',
)
await expect(page.getByTestId('policy-deferred')).toContainText(
'Deferred policy content',
)
})
}
})
})
3 changes: 2 additions & 1 deletion e2e/react-start/streaming-ssr/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
server: {
port: 3000,
},
plugins: [tanstackStart()],
plugins: [tanstackStart(), viteReact()],
})
2 changes: 2 additions & 0 deletions e2e/solid-start/basic/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export default defineConfig({
PORT: String(PORT),
E2E_DIST_DIR: distDir,
E2E_PORT_KEY: e2ePortKey,
STREAMING_SSR_ENTRY_FORM:
process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler',
},
},

Expand Down
Loading
Loading