Skip to content
Open
23 changes: 23 additions & 0 deletions .changeset/usechat-fetcher-server-functions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-preact': patch
'@tanstack/ai-solid': patch
'@tanstack/ai-svelte': patch
'@tanstack/ai-vue': patch
---

Add a `fetcher` option to `ChatClient` and the framework chat hooks
(`useChat` / `createChat`), mirroring the `fetcher` option on the
generation hooks. Pass either `connection` or `fetcher` β€” the XOR is
enforced at the type level via `ChatTransport`.

```ts
useChat({
fetcher: ({ messages }, { signal }) => chatFn({ data: { messages }, signal }),
})
```

The fetcher may return either a `Response` (parsed as SSE) or an
`AsyncIterable<StreamChunk>` (yielded directly). `stream()`,
`fetchServerSentEvents`, `fetchHttpStream`, and `rpcStream` are unchanged.
14 changes: 14 additions & 0 deletions examples/ts-react-chat/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Menu,
Mic,
Music,
Server,
Video,
X,
} from 'lucide-react'
Expand Down Expand Up @@ -197,6 +198,19 @@ export default function Header() {
<Mic size={20} />
<span className="font-medium">Voice Chat (Realtime)</span>
</Link>

<Link
to="/server-fn-chat"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<Server size={20} />
<span className="font-medium">Server Function Chat</span>
</Link>
</nav>
</aside>
</>
Expand Down
29 changes: 28 additions & 1 deletion examples/ts-react-chat/src/lib/server-fns.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import {
chat,
generateAudio,
generateImage,
generateSpeech,
Expand All @@ -10,7 +11,13 @@ import {
summarize,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage, openaiSummarize, openaiVideo } from '@tanstack/ai-openai'
import {
openaiImage,
openaiSummarize,
openaiText,
openaiVideo,
} from '@tanstack/ai-openai'
import type { UIMessage } from '@tanstack/ai'
import {
InvalidModelOverrideError,
UnknownProviderError,
Expand Down Expand Up @@ -365,3 +372,23 @@ export const generateVideoStreamFn = createServerFn({ method: 'POST' })
}),
)
})

// =============================================================================
// Chat server function β€” pairs with useChat({ fetcher })
// =============================================================================

export const chatFn = createServerFn({ method: 'POST' })
.inputValidator(
(data: { messages: Array<UIMessage>; data?: Record<string, any> }) => data,
)
.handler(({ data }) =>
toServerSentEventsResponse(
chat({
adapter: openaiText('gpt-5.2'),
messages: data.messages as any,
systemPrompts: [
'You are a helpful assistant. Keep replies short and friendly.',
],
}),
),
)
Comment on lines +380 to +394
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Restore runtime validation for chatFn.

This handler currently accepts unchecked input: the identity inputValidator lets malformed payloads through, and data.messages as any hides that at the call site. Please switch this to a real schema so bad requests fail before reaching chat().

Suggested fix
 export const chatFn = createServerFn({ method: 'POST' })
   .inputValidator(
-    (data: { messages: Array<UIMessage>; data?: Record<string, any> }) => data,
+    z.object({
+      messages: z.array(z.any()),
+      data: z.record(z.unknown()).optional(),
+    }),
   )
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/lib/server-fns.ts` around lines 380 - 394, Replace
the no-op inputValidator on chatFn with a real runtime schema that validates the
overall payload shape (e.g., an object with messages: Array<UIMessage> and
optional data), validate each message fields used by chat (role, content, id,
etc.), and remove the unsafe cast "data.messages as any" so the handler passes
the typed/validated messages into chat; update the inputValidator call to use
that schema (or a Zod schema) and ensure chatFn.handler expects the validated
type before calling chat/openaiText/toServerSentEventsResponse.

21 changes: 21 additions & 0 deletions examples/ts-react-chat/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// 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 ServerFnChatRouteImport } from './routes/server-fn-chat'
import { Route as RealtimeRouteImport } from './routes/realtime'
import { Route as ImageGenRouteImport } from './routes/image-gen'
import { Route as IndexRouteImport } from './routes/index'
Expand All @@ -31,6 +32,11 @@ import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.spe
import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image'
import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio'

const ServerFnChatRoute = ServerFnChatRouteImport.update({
id: '/server-fn-chat',
path: '/server-fn-chat',
getParentRoute: () => rootRouteImport,
} as any)
const RealtimeRoute = RealtimeRouteImport.update({
id: '/realtime',
path: '/realtime',
Expand Down Expand Up @@ -143,6 +149,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/image-gen': typeof ImageGenRoute
'/realtime': typeof RealtimeRoute
'/server-fn-chat': typeof ServerFnChatRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
'/api/summarize': typeof ApiSummarizeRoute
Expand All @@ -166,6 +173,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/image-gen': typeof ImageGenRoute
'/realtime': typeof RealtimeRoute
'/server-fn-chat': typeof ServerFnChatRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
'/api/summarize': typeof ApiSummarizeRoute
Expand All @@ -190,6 +198,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/image-gen': typeof ImageGenRoute
'/realtime': typeof RealtimeRoute
'/server-fn-chat': typeof ServerFnChatRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
'/api/summarize': typeof ApiSummarizeRoute
Expand All @@ -215,6 +224,7 @@ export interface FileRouteTypes {
| '/'
| '/image-gen'
| '/realtime'
| '/server-fn-chat'
| '/api/image-gen'
| '/api/structured-output'
| '/api/summarize'
Expand All @@ -238,6 +248,7 @@ export interface FileRouteTypes {
| '/'
| '/image-gen'
| '/realtime'
| '/server-fn-chat'
| '/api/image-gen'
| '/api/structured-output'
| '/api/summarize'
Expand All @@ -261,6 +272,7 @@ export interface FileRouteTypes {
| '/'
| '/image-gen'
| '/realtime'
| '/server-fn-chat'
| '/api/image-gen'
| '/api/structured-output'
| '/api/summarize'
Expand All @@ -285,6 +297,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ImageGenRoute: typeof ImageGenRoute
RealtimeRoute: typeof RealtimeRoute
ServerFnChatRoute: typeof ServerFnChatRoute
ApiImageGenRoute: typeof ApiImageGenRoute
ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute
ApiSummarizeRoute: typeof ApiSummarizeRoute
Expand All @@ -307,6 +320,13 @@ export interface RootRouteChildren {

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/server-fn-chat': {
id: '/server-fn-chat'
path: '/server-fn-chat'
fullPath: '/server-fn-chat'
preLoaderRoute: typeof ServerFnChatRouteImport
parentRoute: typeof rootRouteImport
}
'/realtime': {
id: '/realtime'
path: '/realtime'
Expand Down Expand Up @@ -461,6 +481,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ImageGenRoute: ImageGenRoute,
RealtimeRoute: RealtimeRoute,
ServerFnChatRoute: ServerFnChatRoute,
ApiImageGenRoute: ApiImageGenRoute,
ApiStructuredOutputRoute: ApiStructuredOutputRoute,
ApiSummarizeRoute: ApiSummarizeRoute,
Expand Down
101 changes: 101 additions & 0 deletions examples/ts-react-chat/src/routes/server-fn-chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useChat } from '@tanstack/ai-react'
import { Send, Square } from 'lucide-react'
import { chatFn } from '@/lib/server-fns'

export const Route = createFileRoute('/server-fn-chat')({
component: ServerFnChat,
})

function ServerFnChat() {
const { messages, sendMessage, isLoading, error, stop } = useChat({
fetcher: ({ messages }, { signal }) =>
chatFn({ data: { messages }, signal }),
})
const [input, setInput] = useState('')

const handleSubmit = (e: React.FormEvent) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking TS config for UMD global access + JSX mode..."
fd -i 'tsconfig*.json' | while read -r f; do
  echo "### $f"
  cat "$f" | jq '.compilerOptions | {jsx, allowUmdGlobalAccess, types}'
done

echo
echo "Searching for React namespace type usage in TSX files..."
rg -nP --type=tsx '\bReact\.[A-Za-z_]+' -C1

Repository: TanStack/ai

Length of output: 1771


🏁 Script executed:

# Attempt to examine the file and TypeScript config
# First, let's check if the repository is available and explore the file structure

# Check for the file in question
git ls-files | grep -E "(server-fn-chat|tsconfig)" | head -20

Repository: TanStack/ai

Length of output: 931


🏁 Script executed:

# If the file exists, read its contents
if git ls-files | grep -q "examples/ts-react-chat/src/routes/server-fn-chat.tsx"; then
  echo "=== File: server-fn-chat.tsx (lines 1-50) ==="
  head -50 "examples/ts-react-chat/src/routes/server-fn-chat.tsx"
  echo ""
  echo "=== Checking imports and line 18 context ==="
  sed -n '1,30p' "examples/ts-react-chat/src/routes/server-fn-chat.tsx" | cat -n
fi

Repository: TanStack/ai

Length of output: 3156


🏁 Script executed:

# Check tsconfig files
echo "=== Looking for tsconfig files ==="
find . -name "tsconfig*.json" -type f | head -10 | while read f; do
  echo "### $f"
  head -30 "$f"
  echo ""
done

Repository: TanStack/ai

Length of output: 5905


React.FormEvent type reference will fail in strict TypeScript mode without a React import.

Line 18 uses React.FormEvent but only imports useState from React. With moduleResolution: "bundler" and allowUmdGlobalAccess: false (default), the React namespace is unavailable. Fix by importing the type directly: import type { FormEvent } from 'react' and use FormEvent instead of React.FormEvent.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx` at line 18, The handler
signature uses React.FormEvent but the React namespace isn't available under
strict TS settings; update imports to include the type and switch the parameter
type: import type { FormEvent } from 'react' and change the handleSubmit
signature from (e: React.FormEvent) to (e: FormEvent) in the handleSubmit
function so the type resolves under moduleResolution: "bundler" and
allowUmdGlobalAccess: false.

e.preventDefault()
if (!input.trim() || isLoading) return
void sendMessage(input)
setInput('')
}

return (
<div className="flex flex-col h-[calc(100vh-72px)] bg-gray-950 text-gray-100">
<div className="border-b border-gray-800 bg-gray-900/60 px-4 py-3">
<h2 className="text-lg font-semibold">Chat via server function</h2>
<p className="text-xs text-gray-400 mt-1">
<code className="text-cyan-400">
useChat(&#123; fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) =&gt;
chatFn({'{'} data, signal {'}'}) &#125;)
</code>{' '}
Comment on lines +31 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor

Displayed fetcher snippet uses the wrong chatFn call shape.

The rendered example shows chatFn({ data, signal }), but the real call is chatFn({ data: { messages }, signal }). This can cause copy/paste confusion.

✏️ Suggested text fix
-            chatFn({'{'} data, signal {'}'}) &#125;)
+            chatFn({'{'} data: {'{'} messages {'}'}, signal {'}'}) &#125;)
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useChat(&#123; fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) =&gt;
chatFn({'{'} data, signal {'}'}) &#125;)
</code>{' '}
useChat(&#123; fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) =&gt;
chatFn({'{'} data: {'{'} messages {'}'}, signal {'}'}) &#125;)
</code>{' '}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx` around lines 31 - 33,
The fetcher example passed to useChat uses the wrong call shape for chatFn;
update the fetcher so it forwards the messages correctly by calling chatFn with
data containing messages and the signal (i.e., call chatFn with an object whose
data property wraps messages and includes signal) β€” locate the useChat fetcher
and replace the current chatFn({ data, signal }) invocation with the correct
shape chatFn({ data: { messages }, signal }) so copy/pasted examples work as
intended.

β€” the server function returns an SSE{' '}
<code className="text-cyan-400">Response</code>; the chat client
parses it.
</p>
</div>

<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && (
<p className="text-gray-500 text-sm">
Say something to start the chat.
</p>
)}
{messages.map((m) => (
<div
key={m.id}
className={`max-w-2xl rounded-lg px-3 py-2 ${
m.role === 'user'
? 'ml-auto bg-cyan-700/40 border border-cyan-600/40'
: 'mr-auto bg-gray-800 border border-gray-700'
}`}
>
{m.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.content}</span> : null,
)}
</div>
))}
{error && (
<div className="rounded-lg border border-red-700/60 bg-red-900/30 px-3 py-2 text-sm text-red-200">
{error.message}
</div>
)}
</div>

<form
onSubmit={handleSubmit}
className="border-t border-gray-800 bg-gray-900/80 p-3 flex gap-2"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Message..."
disabled={isLoading}
className="flex-1 rounded-lg bg-gray-800 border border-gray-700 px-3 py-2 text-sm focus:outline-none focus:border-cyan-500"
/>
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-3 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white"
aria-label="Stop"
>
<Square size={18} />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-3 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-700 disabled:opacity-50 text-white"
aria-label="Send"
>
<Send size={18} />
</button>
)}
</form>
</div>
)
}
38 changes: 34 additions & 4 deletions packages/typescript/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
normalizeToUIMessage,
} from '@tanstack/ai'
import { DefaultChatClientEventEmitter } from './events'
import { normalizeConnectionAdapter } from './connection-adapters'
import {
fetcherToConnectionAdapter,
normalizeConnectionAdapter,
} from './connection-adapters'
import type {
AnyClientTool,
ContentPart,
Expand All @@ -19,13 +22,34 @@ import type { ChatClientEventEmitter } from './events'
import type {
ChatClientOptions,
ChatClientState,
ChatFetcher,
ConnectionStatus,
MessagePart,
MultimodalContent,
ToolCallPart,
UIMessage,
} from './types'

function resolveTransport(transport: {
connection?: ConnectionAdapter
fetcher?: ChatFetcher
}): ConnectionAdapter {
const hasConnection = transport.connection !== undefined
const hasFetcher = transport.fetcher !== undefined
if (hasConnection && hasFetcher) {
throw new Error(
'ChatClient: pass either `connection` or `fetcher`, not both.',
)
}
if (hasConnection) {
return transport.connection!
}
if (hasFetcher) {
return fetcherToConnectionAdapter(transport.fetcher!)
}
throw new Error('ChatClient: either `connection` or `fetcher` is required.')
}

export class ChatClient {
private processor: StreamProcessor
private connection: SubscribeConnectionAdapter
Expand Down Expand Up @@ -82,7 +106,7 @@ export class ChatClient {
constructor(options: ChatClientOptions) {
this.uniqueId = options.id || this.generateUniqueId('chat')
this.body = options.body || {}
this.connection = normalizeConnectionAdapter(options.connection)
this.connection = normalizeConnectionAdapter(resolveTransport(options))
this.events = new DefaultChatClientEventEmitter(this.uniqueId)

// Build client tools map
Expand Down Expand Up @@ -969,6 +993,7 @@ export class ChatClient {
*/
updateOptions(options: {
connection?: ConnectionAdapter
fetcher?: ChatFetcher
body?: Record<string, any>
tools?: ReadonlyArray<AnyClientTool>
onResponse?: (response?: Response) => void | Promise<void>
Expand All @@ -984,7 +1009,7 @@ export class ChatClient {
context: { toolCallId?: string },
) => void
}): void {
if (options.connection !== undefined) {
if (options.connection !== undefined || options.fetcher !== undefined) {
const wasSubscribed = this.isSubscribed

if (this.isLoading) {
Expand All @@ -999,7 +1024,12 @@ export class ChatClient {
this.resetSessionGenerating()
this.setIsSubscribed(false)
this.setConnectionStatus('disconnected')
this.connection = normalizeConnectionAdapter(options.connection)
this.connection = normalizeConnectionAdapter(
resolveTransport({
connection: options.connection,
fetcher: options.fetcher,
}),
)

if (wasSubscribed) {
this.subscribe()
Expand Down
Loading
Loading