From f55f458677588dcee49245fa5e8a065b0f92c8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 4 Oct 2025 19:45:35 +0200 Subject: [PATCH 01/12] examples: add an new example for typed readable streams in TanStack Start --- .../start-typed-readable-stream/package.json | 28 +++++ .../public/favicon.ico | Bin 0 -> 15406 bytes .../src/routeTree.gen.ts | 68 ++++++++++++ .../src/router.tsx | 20 ++++ .../src/routes/__root.tsx | 41 +++++++ .../src/routes/index.tsx | 100 ++++++++++++++++++ .../src/styles/app.css | 15 +++ .../start-typed-readable-stream/tsconfig.json | 22 ++++ .../vite.config.ts | 17 +++ pnpm-lock.yaml | 43 ++++++++ 10 files changed, 354 insertions(+) create mode 100644 examples/react/start-typed-readable-stream/package.json create mode 100644 examples/react/start-typed-readable-stream/public/favicon.ico create mode 100644 examples/react/start-typed-readable-stream/src/routeTree.gen.ts create mode 100644 examples/react/start-typed-readable-stream/src/router.tsx create mode 100644 examples/react/start-typed-readable-stream/src/routes/__root.tsx create mode 100644 examples/react/start-typed-readable-stream/src/routes/index.tsx create mode 100644 examples/react/start-typed-readable-stream/src/styles/app.css create mode 100644 examples/react/start-typed-readable-stream/tsconfig.json create mode 100644 examples/react/start-typed-readable-stream/vite.config.ts diff --git a/examples/react/start-typed-readable-stream/package.json b/examples/react/start-typed-readable-stream/package.json new file mode 100644 index 0000000000..4066bf2bb8 --- /dev/null +++ b/examples/react/start-typed-readable-stream/package.json @@ -0,0 +1,28 @@ +{ + "name": "tanstack-start-typed-readable-stream", + "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-typed-readable-stream/public/favicon.ico b/examples/react/start-typed-readable-stream/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1a1751676f7e22811b1070572093996c93c87617 GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/examples/react/start-typed-readable-stream/src/routeTree.gen.ts b/examples/react/start-typed-readable-stream/src/routeTree.gen.ts new file mode 100644 index 0000000000..dceedffdc1 --- /dev/null +++ b/examples/react/start-typed-readable-stream/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-typed-readable-stream/src/router.tsx b/examples/react/start-typed-readable-stream/src/router.tsx new file mode 100644 index 0000000000..a464233af9 --- /dev/null +++ b/examples/react/start-typed-readable-stream/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-typed-readable-stream/src/routes/__root.tsx b/examples/react/start-typed-readable-stream/src/routes/__root.tsx new file mode 100644 index 0000000000..df3f6c129d --- /dev/null +++ b/examples/react/start-typed-readable-stream/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-typed-readable-stream/src/routes/index.tsx b/examples/react/start-typed-readable-stream/src/routes/index.tsx new file mode 100644 index 0000000000..7dd09ef581 --- /dev/null +++ b/examples/react/start-typed-readable-stream/src/routes/index.tsx @@ -0,0 +1,100 @@ +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 + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const streamingResponseFn = createServerFn({ + method: 'GET', +}).handler(async () => { + 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, + }, + ], + }), + ) + + // This `ReadableStream` is typed, so each chunk + // will be of type `TextPart`. + const stream = new ReadableStream({ + async start(controller) { + for (const message of messages) { + await sleep(500) + controller.enqueue(message) + } + sleep(500) + controller.close() + }, + }) + + return stream +}) + +export const Route = createFileRoute('/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [message, setMessage] = useState('') + + const getStreamingResponse = useCallback(async () => { + const response = await streamingResponseFn() + + if (!response) { + return + } + + const reader = response.getReader() + let done = false + setMessage('') + 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) { + setMessage((prev) => prev + chunk) + } + } + } + }, []) + + return ( +
+

Typed Readable Stream

+ +
{message}
+
+ ) +} diff --git a/examples/react/start-typed-readable-stream/src/styles/app.css b/examples/react/start-typed-readable-stream/src/styles/app.css new file mode 100644 index 0000000000..a6f35642e4 --- /dev/null +++ b/examples/react/start-typed-readable-stream/src/styles/app.css @@ -0,0 +1,15 @@ +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; +} diff --git a/examples/react/start-typed-readable-stream/tsconfig.json b/examples/react/start-typed-readable-stream/tsconfig.json new file mode 100644 index 0000000000..b3a2d67dfa --- /dev/null +++ b/examples/react/start-typed-readable-stream/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-typed-readable-stream/vite.config.ts b/examples/react/start-typed-readable-stream/vite.config.ts new file mode 100644 index 0000000000..f10c86e79f --- /dev/null +++ b/examples/react/start-typed-readable-stream/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 303cefff7b..79cbfdabbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5796,6 +5796,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-typed-readable-stream: + 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-workos: dependencies: '@radix-ui/themes': From 80c96c120353c3052c6ebb6b6a7ab74f85cb070c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:02:51 +0200 Subject: [PATCH 02/12] Add an example for streaming with an async generator function --- .../src/routes/index.tsx | 96 +++++++++++++++---- .../src/styles/app.css | 9 ++ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/examples/react/start-typed-readable-stream/src/routes/index.tsx b/examples/react/start-typed-readable-stream/src/routes/index.tsx index 7dd09ef581..fb6ff5277c 100644 --- a/examples/react/start-typed-readable-stream/src/routes/index.tsx +++ b/examples/react/start-typed-readable-stream/src/routes/index.tsx @@ -3,9 +3,11 @@ 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.) +/** + 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({ @@ -20,13 +22,11 @@ const textPartSchema = z.object({ export type TextPart = z.infer -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -const streamingResponseFn = createServerFn({ - method: 'GET', -}).handler(async () => { +/** + 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) => @@ -40,16 +40,34 @@ const streamingResponseFn = createServerFn({ ], }), ) + 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 `ReadableStream` is typed, so each chunk +/** + This server function returns a `ReadableStream` + that streams `TextPart` chunks to the client. +*/ +const streamingResponseFn = createServerFn({ + method: 'GET', +}).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) } - sleep(500) controller.close() }, }) @@ -57,14 +75,36 @@ const streamingResponseFn = createServerFn({ 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) { + // Notice how we defined the type of the streamed chunks + // in the generic passed down the Promise constructor + yield new Promise(async (r) => { + // simulate network latency + await sleep(500) + return r(msg) + }) + } + }, +) + export const Route = createFileRoute('/')({ component: RouteComponent, }) function RouteComponent() { - const [message, setMessage] = useState('') + const [readableStreamMessages, setReadableStreamMessages] = useState('') - const getStreamingResponse = useCallback(async () => { + const [asyncGeneratorFuncMessages, setAsyncGeneratorFuncMessages] = + useState('') + + const getTypedReadableStreamResponse = useCallback(async () => { const response = await streamingResponseFn() if (!response) { @@ -73,7 +113,7 @@ function RouteComponent() { const reader = response.getReader() let done = false - setMessage('') + setReadableStreamMessages('') while (!done) { const { value, done: doneReading } = await reader.read() done = doneReading @@ -82,19 +122,35 @@ function RouteComponent() { // here, because it's coming from the typed `ReadableStream` const chunk = value?.choices[0].delta.content if (chunk) { - setMessage((prev) => prev + chunk) + setReadableStreamMessages((prev) => prev + chunk) } } } }, []) + const getResponseFromTheAsyncGenerator = useCallback(async () => { + setAsyncGeneratorFuncMessages('') + for await (const m of await streamingWithAnAsyncGeneratorFn()) { + const chunk = m?.choices[0].delta.content + if (chunk) { + setAsyncGeneratorFuncMessages((prev) => prev + chunk) + } + } + }, []) + return (

Typed Readable Stream

- -
{message}
+
+ + +
{readableStreamMessages}
+
{asyncGeneratorFuncMessages}
+
) } diff --git a/examples/react/start-typed-readable-stream/src/styles/app.css b/examples/react/start-typed-readable-stream/src/styles/app.css index a6f35642e4..e35ad07864 100644 --- a/examples/react/start-typed-readable-stream/src/styles/app.css +++ b/examples/react/start-typed-readable-stream/src/styles/app.css @@ -13,3 +13,12 @@ main { padding: 1em; margin: 0 auto; } + +#streamed-results { + display: grid; + grid-template-columns: 1fr 1fr; +} + +#streamed-results>button { + margin: auto; +} From b1670fdc085421d75a8e78e280b2b1b7e3bc01ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:06:39 +0200 Subject: [PATCH 03/12] Rename the example --- .../package.json | 2 +- .../public/favicon.ico | Bin .../src/routeTree.gen.ts | 0 .../src/router.tsx | 0 .../src/routes/__root.tsx | 0 .../src/routes/index.tsx | 0 .../src/styles/app.css | 0 .../src/utils/index.ts | 28 ++++++++++++++++++ .../tsconfig.json | 0 .../vite.config.ts | 0 10 files changed, 29 insertions(+), 1 deletion(-) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/package.json (91%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/public/favicon.ico (100%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/src/routeTree.gen.ts (100%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/src/router.tsx (100%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/src/routes/__root.tsx (100%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/src/routes/index.tsx (100%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/src/styles/app.css (100%) create mode 100644 examples/react/start-streaming-data-from-server-functions/src/utils/index.ts rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/tsconfig.json (100%) rename examples/react/{start-typed-readable-stream => start-streaming-data-from-server-functions}/vite.config.ts (100%) diff --git a/examples/react/start-typed-readable-stream/package.json b/examples/react/start-streaming-data-from-server-functions/package.json similarity index 91% rename from examples/react/start-typed-readable-stream/package.json rename to examples/react/start-streaming-data-from-server-functions/package.json index 4066bf2bb8..3f9bac9900 100644 --- a/examples/react/start-typed-readable-stream/package.json +++ b/examples/react/start-streaming-data-from-server-functions/package.json @@ -1,5 +1,5 @@ { - "name": "tanstack-start-typed-readable-stream", + "name": "tanstack-start-streaming-data-from-server-functions", "private": true, "sideEffects": false, "type": "module", diff --git a/examples/react/start-typed-readable-stream/public/favicon.ico b/examples/react/start-streaming-data-from-server-functions/public/favicon.ico similarity index 100% rename from examples/react/start-typed-readable-stream/public/favicon.ico rename to examples/react/start-streaming-data-from-server-functions/public/favicon.ico diff --git a/examples/react/start-typed-readable-stream/src/routeTree.gen.ts b/examples/react/start-streaming-data-from-server-functions/src/routeTree.gen.ts similarity index 100% rename from examples/react/start-typed-readable-stream/src/routeTree.gen.ts rename to examples/react/start-streaming-data-from-server-functions/src/routeTree.gen.ts diff --git a/examples/react/start-typed-readable-stream/src/router.tsx b/examples/react/start-streaming-data-from-server-functions/src/router.tsx similarity index 100% rename from examples/react/start-typed-readable-stream/src/router.tsx rename to examples/react/start-streaming-data-from-server-functions/src/router.tsx diff --git a/examples/react/start-typed-readable-stream/src/routes/__root.tsx b/examples/react/start-streaming-data-from-server-functions/src/routes/__root.tsx similarity index 100% rename from examples/react/start-typed-readable-stream/src/routes/__root.tsx rename to examples/react/start-streaming-data-from-server-functions/src/routes/__root.tsx diff --git a/examples/react/start-typed-readable-stream/src/routes/index.tsx b/examples/react/start-streaming-data-from-server-functions/src/routes/index.tsx similarity index 100% rename from examples/react/start-typed-readable-stream/src/routes/index.tsx rename to examples/react/start-streaming-data-from-server-functions/src/routes/index.tsx diff --git a/examples/react/start-typed-readable-stream/src/styles/app.css b/examples/react/start-streaming-data-from-server-functions/src/styles/app.css similarity index 100% rename from examples/react/start-typed-readable-stream/src/styles/app.css rename to examples/react/start-streaming-data-from-server-functions/src/styles/app.css diff --git a/examples/react/start-streaming-data-from-server-functions/src/utils/index.ts b/examples/react/start-streaming-data-from-server-functions/src/utils/index.ts new file mode 100644 index 0000000000..71391ea821 --- /dev/null +++ b/examples/react/start-streaming-data-from-server-functions/src/utils/index.ts @@ -0,0 +1,28 @@ +/** + 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)) +} diff --git a/examples/react/start-typed-readable-stream/tsconfig.json b/examples/react/start-streaming-data-from-server-functions/tsconfig.json similarity index 100% rename from examples/react/start-typed-readable-stream/tsconfig.json rename to examples/react/start-streaming-data-from-server-functions/tsconfig.json diff --git a/examples/react/start-typed-readable-stream/vite.config.ts b/examples/react/start-streaming-data-from-server-functions/vite.config.ts similarity index 100% rename from examples/react/start-typed-readable-stream/vite.config.ts rename to examples/react/start-streaming-data-from-server-functions/vite.config.ts From ff39205366994294d73db49ca949b79ed3847c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:35:57 +0200 Subject: [PATCH 04/12] Clean up the example --- .../src/routes/index.tsx | 4 +-- .../src/utils/index.ts | 28 ------------------- 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 examples/react/start-streaming-data-from-server-functions/src/utils/index.ts 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 index fb6ff5277c..02abdfdfd0 100644 --- 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 @@ -55,9 +55,7 @@ function sleep(ms: number) { This server function returns a `ReadableStream` that streams `TextPart` chunks to the client. */ -const streamingResponseFn = createServerFn({ - method: 'GET', -}).handler(async () => { +const streamingResponseFn = createServerFn().handler(async () => { const messages = generateMessages() // This `ReadableStream` is typed, so each // will be of type `TextPart`. diff --git a/examples/react/start-streaming-data-from-server-functions/src/utils/index.ts b/examples/react/start-streaming-data-from-server-functions/src/utils/index.ts deleted file mode 100644 index 71391ea821..0000000000 --- a/examples/react/start-streaming-data-from-server-functions/src/utils/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - 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)) -} From 41af48d52304e447d9dd963693143c29186c8468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:13:26 +0200 Subject: [PATCH 05/12] Add a guide about streaming --- .../streaming-data-from-server-functions.md | 102 ++++++++++++++++ .../start/framework/react/server-functions.md | 110 +++++++++--------- 2 files changed, 159 insertions(+), 53 deletions(-) create mode 100644 docs/router/framework/react/guide/streaming-data-from-server-functions.md 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 0000000000..3c5771b014 --- /dev/null +++ b/docs/router/framework/react/guide/streaming-data-from-server-functions.md @@ -0,0 +1,102 @@ +--- +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({ + method: "GET", +}).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 = generateMessages(); + for (const msg of messages) { + // Notice how we defined the type of the streamed chunks + // in the generic passed down the Promise constructor + yield new Promise(async (r) => { + // Send the message + return r(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 6e0a0e7386..98474c2ce8 100644 --- a/docs/start/framework/react/server-functions.md +++ b/docs/start/framework/react/server-functions.md @@ -8,15 +8,15 @@ title: Server Functions Server functions let you define server-only logic that can be called from anywhere in your application - loaders, components, hooks, or other server functions. They run on the server but can be invoked from client code seamlessly. ```tsx -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from "@tanstack/react-start"; export const getServerTime = createServerFn().handler(async () => { // This runs only on the server - return new Date().toISOString() -}) + return new Date().toISOString(); +}); // Call from anywhere - components, loaders, hooks, etc. -const time = await getServerTime() +const time = await getServerTime(); ``` Server functions provide server capabilities (database access, environment variables, file system) while maintaining type safety across the network boundary. @@ -26,18 +26,18 @@ Server functions provide server capabilities (database access, environment varia Server functions are created with `createServerFn()` and can specify HTTP method: ```tsx -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from "@tanstack/react-start"; // GET request (default) export const getData = createServerFn().handler(async () => { - return { message: 'Hello from server!' } -}) + return { message: "Hello from server!" }; +}); // POST request -export const saveData = createServerFn({ method: 'POST' }).handler(async () => { +export const saveData = createServerFn({ method: "POST" }).handler(async () => { // Server-only logic - return { success: true } -}) + return { success: true }; +}); ``` ## Where to Call Server Functions @@ -51,18 +51,18 @@ Call server functions from: ```tsx // In a route loader -export const Route = createFileRoute('/posts')({ +export const Route = createFileRoute("/posts")({ loader: () => getPosts(), -}) +}); // In a component function PostList() { - const getPosts = useServerFn(getServerPosts) + const getPosts = useServerFn(getServerPosts); const { data } = useQuery({ - queryKey: ['posts'], + queryKey: ["posts"], queryFn: () => getPosts(), - }) + }); } ``` @@ -73,15 +73,15 @@ Server functions accept a single `data` parameter. Since they cross the network ### Basic Parameters ```tsx -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from "@tanstack/react-start"; -export const greetUser = createServerFn({ method: 'GET' }) +export const greetUser = createServerFn({ method: "GET" }) .inputValidator((data: { name: string }) => data) .handler(async ({ data }) => { - return `Hello, ${data.name}!` - }) + return `Hello, ${data.name}!`; + }); -await greetUser({ data: { name: 'John' } }) +await greetUser({ data: { name: "John" } }); ``` ### Validation with Zod @@ -89,20 +89,20 @@ await greetUser({ data: { name: 'John' } }) For robust validation, use schema libraries like Zod: ```tsx -import { createServerFn } from '@tanstack/react-start' -import { z } from 'zod' +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; const UserSchema = z.object({ name: z.string().min(1), age: z.number().min(0), -}) +}); -export const createUser = createServerFn({ method: 'POST' }) +export const createUser = createServerFn({ method: "POST" }) .inputValidator(UserSchema) .handler(async ({ data }) => { // data is fully typed and validated - return `Created user: ${data.name}, age ${data.age}` - }) + return `Created user: ${data.name}, age ${data.age}`; + }); ``` ### Form Data @@ -110,21 +110,21 @@ export const createUser = createServerFn({ method: 'POST' }) Handle form submissions with FormData: ```tsx -export const submitForm = createServerFn({ method: 'POST' }) +export const submitForm = createServerFn({ method: "POST" }) .inputValidator((data) => { if (!(data instanceof FormData)) { - throw new Error('Expected FormData') + throw new Error("Expected FormData"); } return { - name: data.get('name')?.toString() || '', - email: data.get('email')?.toString() || '', - } + name: data.get("name")?.toString() || "", + email: data.get("email")?.toString() || "", + }; }) .handler(async ({ data }) => { // Process form data - return { success: true } - }) + return { success: true }; + }); ``` ## Error Handling & Redirects @@ -134,20 +134,20 @@ Server functions can throw errors, redirects, and not-found responses that are h ### Basic Errors ```tsx -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from "@tanstack/react-start"; export const riskyFunction = createServerFn().handler(async () => { if (Math.random() > 0.5) { - throw new Error('Something went wrong!') + throw new Error("Something went wrong!"); } - return { success: true } -}) + return { success: true }; +}); // Errors are serialized to the client try { - await riskyFunction() + await riskyFunction(); } catch (error) { - console.log(error.message) // "Something went wrong!" + console.log(error.message); // "Something went wrong!" } ``` @@ -156,18 +156,18 @@ try { Use redirects for authentication, navigation, etc: ```tsx -import { createServerFn } from '@tanstack/react-start' -import { redirect } from '@tanstack/react-router' +import { createServerFn } from "@tanstack/react-start"; +import { redirect } from "@tanstack/react-router"; export const requireAuth = createServerFn().handler(async () => { - const user = await getCurrentUser() + const user = await getCurrentUser(); if (!user) { - throw redirect({ to: '/login' }) + throw redirect({ to: "/login" }); } - return user -}) + return user; +}); ``` ### Not Found @@ -175,20 +175,20 @@ export const requireAuth = createServerFn().handler(async () => { Throw not-found errors for missing resources: ```tsx -import { createServerFn } from '@tanstack/react-start' -import { notFound } from '@tanstack/react-router' +import { createServerFn } from "@tanstack/react-start"; +import { notFound } from "@tanstack/react-router"; export const getPost = createServerFn() .inputValidator((data: { id: string }) => data) .handler(async ({ data }) => { - const post = await db.findPost(data.id) + const post = await db.findPost(data.id); if (!post) { - throw notFound() + throw notFound(); } - return post - }) + return post; + }); ``` ## Advanced Topics @@ -204,9 +204,13 @@ Access request headers, cookies, and response customization: - `setResponseHeader()` - Set custom response headers - `setResponseStatus()` - Custom status codes -### Streaming & Raw Responses +### Streaming + +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 for streaming, binary data, or custom content types. +Return `Response` objects binary data, or custom content types. ### Progressive Enhancement From bc120b4dc3f632eff2d5d27cf574adb90255d818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:13:58 +0200 Subject: [PATCH 06/12] Make it the example cleaner --- .../src/routes/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 02abdfdfd0..4c74174d79 100644 --- 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 @@ -128,8 +128,8 @@ function RouteComponent() { const getResponseFromTheAsyncGenerator = useCallback(async () => { setAsyncGeneratorFuncMessages('') - for await (const m of await streamingWithAnAsyncGeneratorFn()) { - const chunk = m?.choices[0].delta.content + for await (const msg of await streamingWithAnAsyncGeneratorFn()) { + const chunk = msg?.choices[0].delta.content if (chunk) { setAsyncGeneratorFuncMessages((prev) => prev + chunk) } From 1852213575dc59a91ba8ea2314b1497a22dbb8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:16:19 +0200 Subject: [PATCH 07/12] Update pnpm-lock.yaml --- pnpm-lock.yaml | 86 +++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79cbfdabbc..09b5dbb76a 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': @@ -5796,49 +5839,6 @@ 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-typed-readable-stream: - 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-workos: dependencies: '@radix-ui/themes': From 6971dfa3a178cc3d3cbb7a60e08ba0437e18bbd3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Oct 2025 20:17:25 +0000 Subject: [PATCH 08/12] ci: apply automated fixes --- .../streaming-data-from-server-functions.md | 54 +++++----- .../start/framework/react/server-functions.md | 102 +++++++++--------- .../src/styles/app.css | 2 +- 3 files changed, 79 insertions(+), 79 deletions(-) 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 index 3c5771b014..5f031f3f99 100644 --- a/docs/router/framework/react/guide/streaming-data-from-server-functions.md +++ b/docs/router/framework/react/guide/streaming-data-from-server-functions.md @@ -14,18 +14,18 @@ Here's an example for a server function that streams an array of messages to the ```ts type Message = { - content: string; -}; + content: string +} /** This server function returns a `ReadableStream` that streams `Message` chunks to the client. */ const streamingResponseFn = createServerFn({ - method: "GET", + method: 'GET', }).handler(async () => { // These are the messages that you want to send as chunks to the client - const messages: Message[] = generateMessages(); + const messages: Message[] = generateMessages() // This `ReadableStream` is typed, so each // will be of type `Message`. @@ -33,41 +33,41 @@ const streamingResponseFn = createServerFn({ async start(controller) { for (const message of messages) { // Send the message - controller.enqueue(message); + controller.enqueue(message) } - controller.close(); + controller.close() }, - }); + }) - return stream; -}); + return stream +}) ``` When you consume this stream from the client, the streamed chunks will be properly typed: ```ts -const [message, setMessage] = useState(""); +const [message, setMessage] = useState('') const getTypedReadableStreamResponse = useCallback(async () => { - const response = await streamingResponseFn(); + const response = await streamingResponseFn() if (!response) { - return; + return } - const reader = response.getReader(); - let done = false; + const reader = response.getReader() + let done = false while (!done) { - const { value, done: doneReading } = await reader.read(); - done = doneReading; + 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); + const chunk = value.content + setMessage((prev) => prev + chunk) } } -}, []); +}, []) ``` ## Async Generators in Server Functions @@ -77,17 +77,17 @@ A much cleaner approach with the same results is to use an async generator funct ```ts const streamingWithAnAsyncGeneratorFn = createServerFn().handler( async function* () { - const messages = generateMessages(); + const messages = generateMessages() for (const msg of messages) { // Notice how we defined the type of the streamed chunks // in the generic passed down the Promise constructor yield new Promise(async (r) => { // Send the message - return r(msg); - }); + return r(msg) + }) } - } -); + }, +) ``` The client side code will also be leaner: @@ -95,8 +95,8 @@ 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); + 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 98474c2ce8..da62aa5446 100644 --- a/docs/start/framework/react/server-functions.md +++ b/docs/start/framework/react/server-functions.md @@ -8,15 +8,15 @@ title: Server Functions Server functions let you define server-only logic that can be called from anywhere in your application - loaders, components, hooks, or other server functions. They run on the server but can be invoked from client code seamlessly. ```tsx -import { createServerFn } from "@tanstack/react-start"; +import { createServerFn } from '@tanstack/react-start' export const getServerTime = createServerFn().handler(async () => { // This runs only on the server - return new Date().toISOString(); -}); + return new Date().toISOString() +}) // Call from anywhere - components, loaders, hooks, etc. -const time = await getServerTime(); +const time = await getServerTime() ``` Server functions provide server capabilities (database access, environment variables, file system) while maintaining type safety across the network boundary. @@ -26,18 +26,18 @@ Server functions provide server capabilities (database access, environment varia Server functions are created with `createServerFn()` and can specify HTTP method: ```tsx -import { createServerFn } from "@tanstack/react-start"; +import { createServerFn } from '@tanstack/react-start' // GET request (default) export const getData = createServerFn().handler(async () => { - return { message: "Hello from server!" }; -}); + return { message: 'Hello from server!' } +}) // POST request -export const saveData = createServerFn({ method: "POST" }).handler(async () => { +export const saveData = createServerFn({ method: 'POST' }).handler(async () => { // Server-only logic - return { success: true }; -}); + return { success: true } +}) ``` ## Where to Call Server Functions @@ -51,18 +51,18 @@ Call server functions from: ```tsx // In a route loader -export const Route = createFileRoute("/posts")({ +export const Route = createFileRoute('/posts')({ loader: () => getPosts(), -}); +}) // In a component function PostList() { - const getPosts = useServerFn(getServerPosts); + const getPosts = useServerFn(getServerPosts) const { data } = useQuery({ - queryKey: ["posts"], + queryKey: ['posts'], queryFn: () => getPosts(), - }); + }) } ``` @@ -73,15 +73,15 @@ Server functions accept a single `data` parameter. Since they cross the network ### Basic Parameters ```tsx -import { createServerFn } from "@tanstack/react-start"; +import { createServerFn } from '@tanstack/react-start' -export const greetUser = createServerFn({ method: "GET" }) +export const greetUser = createServerFn({ method: 'GET' }) .inputValidator((data: { name: string }) => data) .handler(async ({ data }) => { - return `Hello, ${data.name}!`; - }); + return `Hello, ${data.name}!` + }) -await greetUser({ data: { name: "John" } }); +await greetUser({ data: { name: 'John' } }) ``` ### Validation with Zod @@ -89,20 +89,20 @@ await greetUser({ data: { name: "John" } }); For robust validation, use schema libraries like Zod: ```tsx -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' const UserSchema = z.object({ name: z.string().min(1), age: z.number().min(0), -}); +}) -export const createUser = createServerFn({ method: "POST" }) +export const createUser = createServerFn({ method: 'POST' }) .inputValidator(UserSchema) .handler(async ({ data }) => { // data is fully typed and validated - return `Created user: ${data.name}, age ${data.age}`; - }); + return `Created user: ${data.name}, age ${data.age}` + }) ``` ### Form Data @@ -110,21 +110,21 @@ export const createUser = createServerFn({ method: "POST" }) Handle form submissions with FormData: ```tsx -export const submitForm = createServerFn({ method: "POST" }) +export const submitForm = createServerFn({ method: 'POST' }) .inputValidator((data) => { if (!(data instanceof FormData)) { - throw new Error("Expected FormData"); + throw new Error('Expected FormData') } return { - name: data.get("name")?.toString() || "", - email: data.get("email")?.toString() || "", - }; + name: data.get('name')?.toString() || '', + email: data.get('email')?.toString() || '', + } }) .handler(async ({ data }) => { // Process form data - return { success: true }; - }); + return { success: true } + }) ``` ## Error Handling & Redirects @@ -134,20 +134,20 @@ Server functions can throw errors, redirects, and not-found responses that are h ### Basic Errors ```tsx -import { createServerFn } from "@tanstack/react-start"; +import { createServerFn } from '@tanstack/react-start' export const riskyFunction = createServerFn().handler(async () => { if (Math.random() > 0.5) { - throw new Error("Something went wrong!"); + throw new Error('Something went wrong!') } - return { success: true }; -}); + return { success: true } +}) // Errors are serialized to the client try { - await riskyFunction(); + await riskyFunction() } catch (error) { - console.log(error.message); // "Something went wrong!" + console.log(error.message) // "Something went wrong!" } ``` @@ -156,18 +156,18 @@ try { Use redirects for authentication, navigation, etc: ```tsx -import { createServerFn } from "@tanstack/react-start"; -import { redirect } from "@tanstack/react-router"; +import { createServerFn } from '@tanstack/react-start' +import { redirect } from '@tanstack/react-router' export const requireAuth = createServerFn().handler(async () => { - const user = await getCurrentUser(); + const user = await getCurrentUser() if (!user) { - throw redirect({ to: "/login" }); + throw redirect({ to: '/login' }) } - return user; -}); + return user +}) ``` ### Not Found @@ -175,20 +175,20 @@ export const requireAuth = createServerFn().handler(async () => { Throw not-found errors for missing resources: ```tsx -import { createServerFn } from "@tanstack/react-start"; -import { notFound } from "@tanstack/react-router"; +import { createServerFn } from '@tanstack/react-start' +import { notFound } from '@tanstack/react-router' export const getPost = createServerFn() .inputValidator((data: { id: string }) => data) .handler(async ({ data }) => { - const post = await db.findPost(data.id); + const post = await db.findPost(data.id) if (!post) { - throw notFound(); + throw notFound() } - return post; - }); + return post + }) ``` ## Advanced Topics 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 index e35ad07864..1caa0077b9 100644 --- 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 @@ -19,6 +19,6 @@ main { grid-template-columns: 1fr 1fr; } -#streamed-results>button { +#streamed-results > button { margin: auto; } From b5fd9bad7b6cfc77c708519d9c27620177864901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:44:47 +0200 Subject: [PATCH 09/12] Remove a redundant space --- docs/start/framework/react/server-functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/start/framework/react/server-functions.md b/docs/start/framework/react/server-functions.md index da62aa5446..5b2cdbedf2 100644 --- a/docs/start/framework/react/server-functions.md +++ b/docs/start/framework/react/server-functions.md @@ -206,7 +206,7 @@ Access request headers, cookies, and response customization: ### Streaming -Stream typed data from server functions to the client. See the [Streaming Data from Server Functions guide ](../guide/streaming-data-from-server-functions). +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 From 15b3d3abb3cfd96ac1e719770fa94cbba7b5bf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:56:36 +0200 Subject: [PATCH 10/12] Fix the async promise executor anti-pattern --- .../guide/streaming-data-from-server-functions.md | 15 +++++---------- .../src/routes/index.tsx | 10 +++------- 2 files changed, 8 insertions(+), 17 deletions(-) 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 index 5f031f3f99..6c11eaa754 100644 --- a/docs/router/framework/react/guide/streaming-data-from-server-functions.md +++ b/docs/router/framework/react/guide/streaming-data-from-server-functions.md @@ -21,9 +21,7 @@ type Message = { This server function returns a `ReadableStream` that streams `Message` chunks to the client. */ -const streamingResponseFn = createServerFn({ - method: 'GET', -}).handler(async () => { +const streamingResponseFn = createServerFn().handler(async () => { // These are the messages that you want to send as chunks to the client const messages: Message[] = generateMessages() @@ -77,14 +75,11 @@ A much cleaner approach with the same results is to use an async generator funct ```ts const streamingWithAnAsyncGeneratorFn = createServerFn().handler( async function* () { - const messages = generateMessages() + const messages: Message[] = generateMessages(); for (const msg of messages) { - // Notice how we defined the type of the streamed chunks - // in the generic passed down the Promise constructor - yield new Promise(async (r) => { - // Send the message - return r(msg) - }) + await sleep(500); + // The streamed chunks are still typed as `Message` + yield msg; } }, ) 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 index 4c74174d79..e9b7b93b22 100644 --- 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 @@ -81,13 +81,9 @@ const streamingWithAnAsyncGeneratorFn = createServerFn().handler( async function* () { const messages = generateMessages() for (const msg of messages) { - // Notice how we defined the type of the streamed chunks - // in the generic passed down the Promise constructor - yield new Promise(async (r) => { - // simulate network latency - await sleep(500) - return r(msg) - }) + await sleep(500) + // The streamed chunks are still typed as `TextPart` + yield msg } }, ) From c80eceb4f312ef12e8c7d1a5386b2a8ea3ba4031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:57:49 +0200 Subject: [PATCH 11/12] Update the button labels --- .../src/routes/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index e9b7b93b22..b8448c3e61 100644 --- 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 @@ -137,10 +137,10 @@ function RouteComponent() {

Typed Readable Stream

{readableStreamMessages}
{asyncGeneratorFuncMessages}
From e884da1ed629a257ace99a92b5354cdc734ca636 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:59:03 +0000 Subject: [PATCH 12/12] ci: apply automated fixes --- .../react/guide/streaming-data-from-server-functions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 6c11eaa754..474452e885 100644 --- a/docs/router/framework/react/guide/streaming-data-from-server-functions.md +++ b/docs/router/framework/react/guide/streaming-data-from-server-functions.md @@ -75,11 +75,11 @@ A much cleaner approach with the same results is to use an async generator funct ```ts const streamingWithAnAsyncGeneratorFn = createServerFn().handler( async function* () { - const messages: Message[] = generateMessages(); + const messages: Message[] = generateMessages() for (const msg of messages) { - await sleep(500); + await sleep(500) // The streamed chunks are still typed as `Message` - yield msg; + yield msg } }, )