From ce89206a99d4239408dc0cefc4880c90d7a35d28 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sat, 20 Jun 2026 21:57:52 -0400 Subject: [PATCH 1/2] feat: add `ssr.isBot` router option for streaming SSR bot detection Streaming SSR hardcoded the `isbot` User-Agent check that decides whether to wait for the full document (React `allReady`) before responding or to stream the shell first. Add an `ssr.isBot` router option (boolean | (request) => boolean) to override it; the default (undefined) keeps the current `isbot` behavior. Implemented across the react, solid, and vue adapters. --- .changeset/ssr-isbot-option.md | 16 ++++ .../src/ssr/renderRouterToStream.tsx | 15 ++- .../tests/renderRouterToStream.test.tsx | 91 ++++++++++++++++++- packages/router-core/src/router.ts | 14 +++ .../src/ssr/renderRouterToStream.tsx | 13 ++- .../src/ssr/renderRouterToStream.tsx | 13 ++- 6 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .changeset/ssr-isbot-option.md diff --git a/.changeset/ssr-isbot-option.md b/.changeset/ssr-isbot-option.md new file mode 100644 index 0000000000..82499b91be --- /dev/null +++ b/.changeset/ssr-isbot-option.md @@ -0,0 +1,16 @@ +--- +'@tanstack/router-core': minor +'@tanstack/react-router': minor +'@tanstack/solid-router': minor +'@tanstack/vue-router': minor +--- + +feat: add `ssr.isBot` router option to configure streaming SSR bot detection + +Streaming SSR waits for the full document (React's `allReady`) for requests that `isbot` flags by their `User-Agent`, and streams the shell first for everyone else. This was hardcoded, so performance auditors (Lighthouse, PageSpeed Insights, WebPageTest, …) — which `isbot` classifies as bots — were measured on the buffered path instead of the streaming path real users get. + +The new `ssr.isBot` router option overrides that decision while keeping the current behavior by default: + +- `undefined` (default): unchanged — use the built-in `isbot` User-Agent check. +- `boolean`: force the bot (`true`) or non-bot (`false`) path for every request. +- `(request) => boolean`: provide a custom predicate. diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 46275a5421..61f508ab01 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -41,6 +41,15 @@ const isAbortError = (request: Request, error: unknown) => (request.signal.aborted && error === request.signal.reason) || (error instanceof Error && error.name === 'AbortError') +// `ssr.isBot` lets apps override the default `isbot` User-Agent check that +// decides whether to wait for the full document before streaming. +const resolveIsBot = (router: AnyRouter, request: Request): boolean => { + const isBot = router.options.ssr?.isBot + if (typeof isBot === 'function') return isBot(request) + if (typeof isBot === 'boolean') return isBot + return isbot(request.headers.get('User-Agent')) +} + export const renderRouterToStream = async ({ request, router, @@ -52,6 +61,8 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: ReactNode }) => { + const isBotRequest = resolveIsBot(router, request) + if (typeof ReactDOMServer.renderToReadableStream === 'function') { const stream = await ReactDOMServer.renderToReadableStream(children, { signal: request.signal, @@ -64,7 +75,7 @@ export const renderRouterToStream = async ({ }, }) - if (isbot(request.headers.get('User-Agent'))) { + if (isBotRequest) { await waitForReadyOrAbort(stream.allReady, request.signal) } @@ -150,7 +161,7 @@ export const renderRouterToStream = async ({ pipeable = ReactDOMServer.renderToPipeableStream(children, { nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, - ...(isbot(request.headers.get('User-Agent')) + ...(isBotRequest ? { onAllReady() { pipeable!.pipe(reactAppPassthrough) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index 74e180a552..d74ba972fc 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -20,11 +20,14 @@ afterEach(() => { vi.restoreAllMocks() }) -async function buildRouter() { +async function buildRouter(ssr?: { + isBot?: boolean | ((request: Request) => boolean) +}) { const rootRoute = createRootRoute({ component: () => null }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), routeTree: rootRoute, + ...(ssr ? { ssr } : {}), }) router.isServer = true attachRouterServerSsrUtils({ router, manifest: undefined }) @@ -232,3 +235,89 @@ describe('renderRouterToStream - pipeable sync errors', () => { } }) }) + +describe('renderRouterToStream - bot detection (ssr.isBot)', () => { + const BOT_UA = 'Googlebot/2.1 (+http://www.google.com/bot.html)' + const HUMAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + + function requestWith(headers: Record) { + return new Request('http://localhost/', { headers }) + } + + // Captures whether renderToPipeableStream was given `onAllReady` (bot, wait + // for the full document) or `onShellReady` (stream the shell first). + async function getStreamMode( + router: Awaited>, + request: Request, + ): Promise<'allReady' | 'shellReady' | undefined> { + let mode: 'allReady' | 'shellReady' | undefined + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, opts) => { + mode = opts.onAllReady ? 'allReady' : 'shellReady' + queueMicrotask(() => (opts.onAllReady ?? opts.onShellReady)()) + return { abort: vi.fn(), pipe: vi.fn() } + }, + ) + + const result = await renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + children: null, + }) + try { + return mode + } finally { + await unwrapResponse(result) + .body?.cancel() + .catch(() => {}) + router.serverSsr?.cleanup() + } + } + + test('default: bot User-Agent waits for allReady', async () => { + const mode = await getStreamMode( + await buildRouter(), + requestWith({ 'user-agent': BOT_UA }), + ) + expect(mode).toBe('allReady') + }) + + test('default: human User-Agent streams the shell', async () => { + const mode = await getStreamMode( + await buildRouter(), + requestWith({ 'user-agent': HUMAN_UA }), + ) + expect(mode).toBe('shellReady') + }) + + test('ssr.isBot=false: bot User-Agent still streams the shell', async () => { + const mode = await getStreamMode( + await buildRouter({ isBot: false }), + requestWith({ 'user-agent': BOT_UA }), + ) + expect(mode).toBe('shellReady') + }) + + test('ssr.isBot=true: human User-Agent waits for allReady', async () => { + const mode = await getStreamMode( + await buildRouter({ isBot: true }), + requestWith({ 'user-agent': HUMAN_UA }), + ) + expect(mode).toBe('allReady') + }) + + test('ssr.isBot predicate receives the request and controls the mode', async () => { + const isBot = vi.fn( + (request: Request) => request.headers.get('x-prerender') === '1', + ) + const router = await buildRouter({ isBot }) + const request = requestWith({ 'user-agent': HUMAN_UA, 'x-prerender': '1' }) + + const mode = await getStreamMode(router, request) + + expect(mode).toBe('allReady') + expect(isBot).toHaveBeenCalledWith(request) + }) +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..f5cfe36b14 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -509,6 +509,20 @@ export interface RouterOptions< origin?: string ssr?: { nonce?: string + /** + * Determines whether a request should be treated as a bot/crawler. + * + * When a request is considered a bot, streaming SSR waits for the entire + * document to render (React's `allReady`) before responding, so crawlers + * receive complete HTML instead of a progressively streamed shell. + * + * - `undefined` (default): use the built-in `isbot` User-Agent check. + * - `boolean`: force the bot (`true`) or non-bot (`false`) path for every request. + * - `(request) => boolean`: provide a custom predicate. + * + * @default undefined + */ + isBot?: boolean | ((request: Request) => boolean) } } diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8914c117d9..72b3b344d8 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -11,6 +11,15 @@ import type { AnyRouter } from '@tanstack/router-core' const noop = () => {} +// `ssr.isBot` lets apps override the default `isbot` User-Agent check that +// decides whether to wait for the server renderer before streaming. +const resolveIsBot = (router: AnyRouter, request: Request): boolean => { + const isBot = router.options.ssr?.isBot + if (typeof isBot === 'function') return isBot(request) + if (typeof isBot === 'boolean') return isBot + return isbot(request.headers.get('User-Agent')) +} + // Bot responses wait for the server renderer before streaming. If the request // disconnects during that wait, unblock so the pipe can abort and clean up. async function waitForReadyOrAbort( @@ -44,6 +53,8 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: () => JSXElement }) => { + const isBotRequest = resolveIsBot(router, request) + const { writable, readable } = new TransformStream() const docType = Solid.ssr('') @@ -121,7 +132,7 @@ export const renderRouterToStream = async ({ }) } - if (isbot(request.headers.get('User-Agent'))) { + if (isBotRequest) { await waitForReadyOrAbort( Promise.resolve(stream as unknown), request.signal, diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index efad4fc2eb..d141e074a5 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -14,6 +14,15 @@ const isAbortError = (request: Request, error: unknown) => (error instanceof Error && error.name === 'AbortError') || (error as any)?.code === 'ABORT_ERR' +// `ssr.isBot` lets apps override the default `isbot` User-Agent check that +// decides whether to wait for the full document before streaming. +const resolveIsBot = (router: AnyRouter, request: Request): boolean => { + const isBot = router.options.ssr?.isBot + if (typeof isBot === 'function') return isBot(request) + if (typeof isBot === 'boolean') return isBot + return isbot(request.headers.get('User-Agent')) +} + function prependDoctype( readable: globalThis.ReadableStream, ): NodeReadableStream { @@ -76,7 +85,9 @@ export const renderRouterToStream = async ({ }) => { const app = Vue.createSSRApp(App, { router }) - if (isbot(request.headers.get('User-Agent'))) { + const isBotRequest = resolveIsBot(router, request) + + if (isBotRequest) { try { let cleanupAbortListener: (() => void) | undefined const abortPromise = new Promise((_, reject) => { From 0184ecca22ea642cce72d8fa353c0aee96bf3c6b Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 21 Jun 2026 13:18:10 -0400 Subject: [PATCH 2/2] refactor: move isBot from router ssr option to renderRouterToStream createRouter runs isomorphically, so an `ssr.isBot` predicate (and anything it imports) would be bundled into the client. Move the option to `renderRouterToStream` (in ssr/server, server-only) so the bot decision is made in the request handler instead. Default behavior is unchanged. Reverts the router-core RouterOptions change. --- .changeset/ssr-isbot-option.md | 28 +++++++++-- .../src/ssr/renderRouterToStream.tsx | 21 ++++---- .../tests/renderRouterToStream.test.tsx | 48 ++++++++----------- packages/router-core/src/router.ts | 14 ------ .../src/ssr/renderRouterToStream.tsx | 21 ++++---- .../src/ssr/renderRouterToStream.tsx | 21 ++++---- 6 files changed, 75 insertions(+), 78 deletions(-) diff --git a/.changeset/ssr-isbot-option.md b/.changeset/ssr-isbot-option.md index 82499b91be..10f6c0a8d5 100644 --- a/.changeset/ssr-isbot-option.md +++ b/.changeset/ssr-isbot-option.md @@ -1,16 +1,34 @@ --- -'@tanstack/router-core': minor '@tanstack/react-router': minor '@tanstack/solid-router': minor '@tanstack/vue-router': minor --- -feat: add `ssr.isBot` router option to configure streaming SSR bot detection +feat: add `isBot` option to `renderRouterToStream` to configure streaming SSR bot detection Streaming SSR waits for the full document (React's `allReady`) for requests that `isbot` flags by their `User-Agent`, and streams the shell first for everyone else. This was hardcoded, so performance auditors (Lighthouse, PageSpeed Insights, WebPageTest, …) — which `isbot` classifies as bots — were measured on the buffered path instead of the streaming path real users get. -The new `ssr.isBot` router option overrides that decision while keeping the current behavior by default: +`renderRouterToStream` now accepts an `isBot` option so the decision can be made in the server request handler (it lives in `ssr/server` and never ships to the client). The default is unchanged. -- `undefined` (default): unchanged — use the built-in `isbot` User-Agent check. -- `boolean`: force the bot (`true`) or non-bot (`false`) path for every request. +- `undefined` (default): use the built-in `isbot` User-Agent check. +- `boolean`: force the bot (`true`) or non-bot (`false`) path for the request. - `(request) => boolean`: provide a custom predicate. + +```tsx +import { + createRequestHandler, + renderRouterToStream, + RouterServer, +} from '@tanstack/react-router/ssr/server' + +createRequestHandler({ request, createRouter })( + ({ request, router, responseHeaders }) => + renderRouterToStream({ + request, + router, + responseHeaders, + children: , + isBot: false, + }), +) +``` diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 61f508ab01..6aa1424f57 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -41,27 +41,28 @@ const isAbortError = (request: Request, error: unknown) => (request.signal.aborted && error === request.signal.reason) || (error instanceof Error && error.name === 'AbortError') -// `ssr.isBot` lets apps override the default `isbot` User-Agent check that -// decides whether to wait for the full document before streaming. -const resolveIsBot = (router: AnyRouter, request: Request): boolean => { - const isBot = router.options.ssr?.isBot - if (typeof isBot === 'function') return isBot(request) - if (typeof isBot === 'boolean') return isBot - return isbot(request.headers.get('User-Agent')) -} - export const renderRouterToStream = async ({ request, router, responseHeaders, children, + isBot, }: { request: Request router: AnyRouter responseHeaders: Headers children: ReactNode + /** + * Whether to treat the request as a bot/crawler. Bots wait for the full + * document (React's `allReady`) before responding instead of streaming the + * shell first. Defaults to the built-in `isbot` User-Agent check. + */ + isBot?: boolean | ((request: Request) => boolean) }) => { - const isBotRequest = resolveIsBot(router, request) + const isBotRequest = + typeof isBot === 'function' + ? isBot(request) + : (isBot ?? isbot(request.headers.get('User-Agent'))) if (typeof ReactDOMServer.renderToReadableStream === 'function') { const stream = await ReactDOMServer.renderToReadableStream(children, { diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index d74ba972fc..893ac6104a 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -20,14 +20,11 @@ afterEach(() => { vi.restoreAllMocks() }) -async function buildRouter(ssr?: { - isBot?: boolean | ((request: Request) => boolean) -}) { +async function buildRouter() { const rootRoute = createRootRoute({ component: () => null }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), routeTree: rootRoute, - ...(ssr ? { ssr } : {}), }) router.isServer = true attachRouterServerSsrUtils({ router, manifest: undefined }) @@ -236,7 +233,7 @@ describe('renderRouterToStream - pipeable sync errors', () => { }) }) -describe('renderRouterToStream - bot detection (ssr.isBot)', () => { +describe('renderRouterToStream - bot detection (isBot option)', () => { const BOT_UA = 'Googlebot/2.1 (+http://www.google.com/bot.html)' const HUMAN_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' @@ -248,9 +245,10 @@ describe('renderRouterToStream - bot detection (ssr.isBot)', () => { // Captures whether renderToPipeableStream was given `onAllReady` (bot, wait // for the full document) or `onShellReady` (stream the shell first). async function getStreamMode( - router: Awaited>, request: Request, + isBot?: boolean | ((request: Request) => boolean), ): Promise<'allReady' | 'shellReady' | undefined> { + const router = await buildRouter() let mode: 'allReady' | 'shellReady' | undefined reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( (_children, opts) => { @@ -265,6 +263,7 @@ describe('renderRouterToStream - bot detection (ssr.isBot)', () => { router, responseHeaders: new Headers(), children: null, + isBot, }) try { return mode @@ -277,45 +276,36 @@ describe('renderRouterToStream - bot detection (ssr.isBot)', () => { } test('default: bot User-Agent waits for allReady', async () => { - const mode = await getStreamMode( - await buildRouter(), - requestWith({ 'user-agent': BOT_UA }), + expect(await getStreamMode(requestWith({ 'user-agent': BOT_UA }))).toBe( + 'allReady', ) - expect(mode).toBe('allReady') }) test('default: human User-Agent streams the shell', async () => { - const mode = await getStreamMode( - await buildRouter(), - requestWith({ 'user-agent': HUMAN_UA }), + expect(await getStreamMode(requestWith({ 'user-agent': HUMAN_UA }))).toBe( + 'shellReady', ) - expect(mode).toBe('shellReady') }) - test('ssr.isBot=false: bot User-Agent still streams the shell', async () => { - const mode = await getStreamMode( - await buildRouter({ isBot: false }), - requestWith({ 'user-agent': BOT_UA }), - ) - expect(mode).toBe('shellReady') + test('isBot=false: bot User-Agent still streams the shell', async () => { + expect( + await getStreamMode(requestWith({ 'user-agent': BOT_UA }), false), + ).toBe('shellReady') }) - test('ssr.isBot=true: human User-Agent waits for allReady', async () => { - const mode = await getStreamMode( - await buildRouter({ isBot: true }), - requestWith({ 'user-agent': HUMAN_UA }), - ) - expect(mode).toBe('allReady') + test('isBot=true: human User-Agent waits for allReady', async () => { + expect( + await getStreamMode(requestWith({ 'user-agent': HUMAN_UA }), true), + ).toBe('allReady') }) - test('ssr.isBot predicate receives the request and controls the mode', async () => { + test('isBot predicate receives the request and controls the mode', async () => { const isBot = vi.fn( (request: Request) => request.headers.get('x-prerender') === '1', ) - const router = await buildRouter({ isBot }) const request = requestWith({ 'user-agent': HUMAN_UA, 'x-prerender': '1' }) - const mode = await getStreamMode(router, request) + const mode = await getStreamMode(request, isBot) expect(mode).toBe('allReady') expect(isBot).toHaveBeenCalledWith(request) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index f5cfe36b14..2197dab737 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -509,20 +509,6 @@ export interface RouterOptions< origin?: string ssr?: { nonce?: string - /** - * Determines whether a request should be treated as a bot/crawler. - * - * When a request is considered a bot, streaming SSR waits for the entire - * document to render (React's `allReady`) before responding, so crawlers - * receive complete HTML instead of a progressively streamed shell. - * - * - `undefined` (default): use the built-in `isbot` User-Agent check. - * - `boolean`: force the bot (`true`) or non-bot (`false`) path for every request. - * - `(request) => boolean`: provide a custom predicate. - * - * @default undefined - */ - isBot?: boolean | ((request: Request) => boolean) } } diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 72b3b344d8..0e7c034106 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -11,15 +11,6 @@ import type { AnyRouter } from '@tanstack/router-core' const noop = () => {} -// `ssr.isBot` lets apps override the default `isbot` User-Agent check that -// decides whether to wait for the server renderer before streaming. -const resolveIsBot = (router: AnyRouter, request: Request): boolean => { - const isBot = router.options.ssr?.isBot - if (typeof isBot === 'function') return isBot(request) - if (typeof isBot === 'boolean') return isBot - return isbot(request.headers.get('User-Agent')) -} - // Bot responses wait for the server renderer before streaming. If the request // disconnects during that wait, unblock so the pipe can abort and clean up. async function waitForReadyOrAbort( @@ -47,13 +38,23 @@ export const renderRouterToStream = async ({ router, responseHeaders, children, + isBot, }: { request: Request router: AnyRouter responseHeaders: Headers children: () => JSXElement + /** + * Whether to treat the request as a bot/crawler. Bots wait for the full + * server render before streaming. Defaults to the built-in `isbot` + * User-Agent check. + */ + isBot?: boolean | ((request: Request) => boolean) }) => { - const isBotRequest = resolveIsBot(router, request) + const isBotRequest = + typeof isBot === 'function' + ? isBot(request) + : (isBot ?? isbot(request.headers.get('User-Agent'))) const { writable, readable } = new TransformStream() diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index d141e074a5..ba6f7b3b49 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -14,15 +14,6 @@ const isAbortError = (request: Request, error: unknown) => (error instanceof Error && error.name === 'AbortError') || (error as any)?.code === 'ABORT_ERR' -// `ssr.isBot` lets apps override the default `isbot` User-Agent check that -// decides whether to wait for the full document before streaming. -const resolveIsBot = (router: AnyRouter, request: Request): boolean => { - const isBot = router.options.ssr?.isBot - if (typeof isBot === 'function') return isBot(request) - if (typeof isBot === 'boolean') return isBot - return isbot(request.headers.get('User-Agent')) -} - function prependDoctype( readable: globalThis.ReadableStream, ): NodeReadableStream { @@ -77,15 +68,25 @@ export const renderRouterToStream = async ({ router, responseHeaders, App, + isBot, }: { request: Request router: AnyRouter responseHeaders: Headers App: Component + /** + * Whether to treat the request as a bot/crawler. Bots wait for the full + * document before streaming. Defaults to the built-in `isbot` User-Agent + * check. + */ + isBot?: boolean | ((request: Request) => boolean) }) => { const app = Vue.createSSRApp(App, { router }) - const isBotRequest = resolveIsBot(router, request) + const isBotRequest = + typeof isBot === 'function' + ? isBot(request) + : (isBot ?? isbot(request.headers.get('User-Agent'))) if (isBotRequest) { try {