diff --git a/docs/router/framework/react/guide/streaming-data-from-server-functions.md b/docs/router/framework/react/guide/streaming-data-from-server-functions.md new file mode 100644 index 00000000000..474452e8856 --- /dev/null +++ b/docs/router/framework/react/guide/streaming-data-from-server-functions.md @@ -0,0 +1,97 @@ +--- +title: Streaming Data from Server Functions +--- + +Streaming data from the server has become very popular thanks to the rise of AI apps. Luckily, it's a pretty easy task with TanStack Start, and what's even better: the streamed data is typed! + +The two most popular ways of streaming data from server functions are using `ReadableStream`-s or async generators. + +You can see how to implement both in the [Streaming Data From Server Functions example](https://github.com/TanStack/router/tree/main/examples/react/start-streaming-data-from-server-functions). + +## Typed Readable Streams + +Here's an example for a server function that streams an array of messages to the client in a type-safe manner: + +```ts +type Message = { + content: string +} + +/** + This server function returns a `ReadableStream` + that streams `Message` chunks to the client. +*/ +const streamingResponseFn = createServerFn().handler(async () => { + // These are the messages that you want to send as chunks to the client + const messages: Message[] = generateMessages() + + // This `ReadableStream` is typed, so each + // will be of type `Message`. + const stream = new ReadableStream({ + async start(controller) { + for (const message of messages) { + // Send the message + controller.enqueue(message) + } + controller.close() + }, + }) + + return stream +}) +``` + +When you consume this stream from the client, the streamed chunks will be properly typed: + +```ts +const [message, setMessage] = useState('') + +const getTypedReadableStreamResponse = useCallback(async () => { + const response = await streamingResponseFn() + + if (!response) { + return + } + + const reader = response.getReader() + let done = false + while (!done) { + const { value, done: doneReading } = await reader.read() + done = doneReading + if (value) { + // Notice how we know the value of `chunk` (`Message | undefined`) + // here, because it's coming from the typed `ReadableStream` + const chunk = value.content + setMessage((prev) => prev + chunk) + } + } +}, []) +``` + +## Async Generators in Server Functions + +A much cleaner approach with the same results is to use an async generator function: + +```ts +const streamingWithAnAsyncGeneratorFn = createServerFn().handler( + async function* () { + const messages: Message[] = generateMessages() + for (const msg of messages) { + await sleep(500) + // The streamed chunks are still typed as `Message` + yield msg + } + }, +) +``` + +The client side code will also be leaner: + +```ts +const getResponseFromTheAsyncGenerator = useCallback(async () => { + for await (const msg of await streamingWithAnAsyncGeneratorFn()) { + const chunk = msg.content + setMessages((prev) => prev + chunk) + } +}, []) +``` diff --git a/docs/start/framework/react/server-functions.md b/docs/start/framework/react/server-functions.md index 6e0a0e7386d..5b2cdbedf2b 100644 --- a/docs/start/framework/react/server-functions.md +++ b/docs/start/framework/react/server-functions.md @@ -204,9 +204,13 @@ Access request headers, cookies, and response customization: - `setResponseHeader()` - Set custom response headers - `setResponseStatus()` - Custom status codes -### Streaming & Raw Responses +### Streaming -Return `Response` objects for streaming, binary data, or custom content types. +Stream typed data from server functions to the client. See the [Streaming Data from Server Functions guide](../guide/streaming-data-from-server-functions). + +### Raw Responses + +Return `Response` objects binary data, or custom content types. ### Progressive Enhancement diff --git a/examples/react/start-streaming-data-from-server-functions/package.json b/examples/react/start-streaming-data-from-server-functions/package.json new file mode 100644 index 00000000000..3f9bac9900d --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/package.json @@ -0,0 +1,28 @@ +{ + "name": "tanstack-start-streaming-data-from-server-functions", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "vite start" + }, + "dependencies": { + "@tanstack/react-router": "^1.132.33", + "@tanstack/react-router-devtools": "^1.132.33", + "@tanstack/react-start": "^1.132.36", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.5.4", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/examples/react/start-streaming-data-from-server-functions/public/favicon.ico b/examples/react/start-streaming-data-from-server-functions/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/examples/react/start-streaming-data-from-server-functions/public/favicon.ico differ diff --git a/examples/react/start-streaming-data-from-server-functions/src/routeTree.gen.ts b/examples/react/start-streaming-data-from-server-functions/src/routeTree.gen.ts new file mode 100644 index 00000000000..dceedffdc12 --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/src/routeTree.gen.ts @@ -0,0 +1,68 @@ +/* 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 IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +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/examples/react/start-streaming-data-from-server-functions/src/router.tsx b/examples/react/start-streaming-data-from-server-functions/src/router.tsx new file mode 100644 index 00000000000..a464233af90 --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/src/router.tsx @@ -0,0 +1,20 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultNotFoundComponent: () =>

not found

, + scrollRestoration: true, + }) + + return router +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/examples/react/start-streaming-data-from-server-functions/src/routes/__root.tsx b/examples/react/start-streaming-data-from-server-functions/src/routes/__root.tsx new file mode 100644 index 00000000000..df3f6c129d9 --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/src/routes/__root.tsx @@ -0,0 +1,41 @@ +/// +import * as React from 'react' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + + ) +} diff --git a/examples/react/start-streaming-data-from-server-functions/src/routes/index.tsx b/examples/react/start-streaming-data-from-server-functions/src/routes/index.tsx new file mode 100644 index 00000000000..b8448c3e617 --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/src/routes/index.tsx @@ -0,0 +1,150 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useCallback, useState } from 'react' +import { z } from 'zod' + +/** + This schema will be used to define the type + of each chunk in the `ReadableStream`. + (It mimics OpenAI's streaming response format.) +*/ +const textPartSchema = z.object({ + choices: z.array( + z.object({ + delta: z.object({ + content: z.string().optional(), + }), + index: z.number(), + finish_reason: z.string().nullable(), + }), + ), +}) + +export type TextPart = z.infer + +/** + This helper function generates the array of messages + that we'll stream to the client. +*/ +function generateMessages() { + const messages = Array.from({ length: 10 }, () => + Math.floor(Math.random() * 100), + ).map((n, i) => + textPartSchema.parse({ + choices: [ + { + delta: { content: `Number #${i + 1}: ${n}\n` }, + index: i, + finish_reason: null, + }, + ], + }), + ) + return messages +} + +/** + This helper function is used to simulate the + delay between each message being sent. +*/ +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + This server function returns a `ReadableStream` + that streams `TextPart` chunks to the client. +*/ +const streamingResponseFn = createServerFn().handler(async () => { + const messages = generateMessages() + // This `ReadableStream` is typed, so each + // will be of type `TextPart`. + const stream = new ReadableStream({ + async start(controller) { + for (const message of messages) { + // simulate network latency + await sleep(500) + controller.enqueue(message) + } + controller.close() + }, + }) + + return stream +}) + +/** + You can also use an async generator function to stream + typed chunks to the client. +*/ +const streamingWithAnAsyncGeneratorFn = createServerFn().handler( + async function* () { + const messages = generateMessages() + for (const msg of messages) { + await sleep(500) + // The streamed chunks are still typed as `TextPart` + yield msg + } + }, +) + +export const Route = createFileRoute('/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [readableStreamMessages, setReadableStreamMessages] = useState('') + + const [asyncGeneratorFuncMessages, setAsyncGeneratorFuncMessages] = + useState('') + + const getTypedReadableStreamResponse = useCallback(async () => { + const response = await streamingResponseFn() + + if (!response) { + return + } + + const reader = response.getReader() + let done = false + setReadableStreamMessages('') + while (!done) { + const { value, done: doneReading } = await reader.read() + done = doneReading + if (value) { + // Notice how we know the value of `chunk` (`TextPart | undefined`) + // here, because it's coming from the typed `ReadableStream` + const chunk = value?.choices[0].delta.content + if (chunk) { + setReadableStreamMessages((prev) => prev + chunk) + } + } + } + }, []) + + const getResponseFromTheAsyncGenerator = useCallback(async () => { + setAsyncGeneratorFuncMessages('') + for await (const msg of await streamingWithAnAsyncGeneratorFn()) { + const chunk = msg?.choices[0].delta.content + if (chunk) { + setAsyncGeneratorFuncMessages((prev) => prev + chunk) + } + } + }, []) + + return ( +
+

Typed Readable Stream

+
+ + +
{readableStreamMessages}
+
{asyncGeneratorFuncMessages}
+
+
+ ) +} diff --git a/examples/react/start-streaming-data-from-server-functions/src/styles/app.css b/examples/react/start-streaming-data-from-server-functions/src/styles/app.css new file mode 100644 index 00000000000..1caa0077b9b --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/src/styles/app.css @@ -0,0 +1,24 @@ +body { + font-family: + Gordita, Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', + sans-serif; +} + +a { + margin-right: 1rem; +} + +main { + text-align: center; + padding: 1em; + margin: 0 auto; +} + +#streamed-results { + display: grid; + grid-template-columns: 1fr 1fr; +} + +#streamed-results > button { + margin: auto; +} diff --git a/examples/react/start-streaming-data-from-server-functions/tsconfig.json b/examples/react/start-streaming-data-from-server-functions/tsconfig.json new file mode 100644 index 00000000000..b3a2d67dfa6 --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "public/script*.js"], + "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/examples/react/start-streaming-data-from-server-functions/vite.config.ts b/examples/react/start-streaming-data-from-server-functions/vite.config.ts new file mode 100644 index 00000000000..f10c86e79fc --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/vite.config.ts @@ -0,0 +1,17 @@ +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303cefff7b6..09b5dbb76ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5610,6 +5610,49 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + examples/react/start-streaming-data-from-server-functions: + 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.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + 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.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + 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.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + examples/react/start-supabase-basic: dependencies: '@supabase/ssr':