{"slug": "unlocking-ai-in-your-apps-with-tanstack-ai", "title": "Unlocking AI in Your Apps with TanStack AI", "summary": "TanStack released TanStack AI, an ecosystem of strongly typed AI libraries that developers install via npm packages @tanstack/ai and @tanstack/ai-react to add AI features to applications. The libraries support non-streaming chat requests through adapters such as @tanstack/ai-vercel-gateway, with a React useChat hook returning messages, a sendMessage function, and an isLoading indicator, and the author states a second part will cover structured output with streaming. All code samples are published in a GitHub repository at github.com/arackaf/tanstack-ai-blog-post.", "body_md": "[TanStack AI](https://tanstack.com/ai/latest) is the latest offering from the TanStack universe. It’s an ecosystem of AI libraries that lets you add virtually any AI feature you can imagine to your apps. Like other TanStack libraries, it’s fully featured and *extremely* strongly typed.\n\nThis will be a two-part post introducing some of the basic, common features you’re most likely to reach for in everyday applications. Future posts will go further and use TanStack AI to do things like spin up agents.\n\nPart 1 will cover basic setup and AI requests, streaming, persistence, and resumability. I know that sounds like a lot, but TanStack makes this stuff incredibly simple—bordering on turnkey—so we’ll cover it fairly quickly. Part 2 *(coming soon!)* will get into structured output, also with streaming.\n\nAll code samples from both parts are in [this repo](https://github.com/arackaf/tanstack-ai-blog-post).\n\nLet’s get started!\n\n## \n\nLet’s install some packages.\n\n```\nnpm i @tanstack/ai @tanstack/ai-react\n```\n\nSince I love routing all my AI requests through *one* gateway, no matter who owns the model being used, let’s also install the adapter for Vercel’s AI Gateway.\n\n```\nnpm i @tanstack/ai-vercel-gateway\n```\n\nThen, to make requests actually work, we’ll need an entry in our `.env` file.\n\n```\nAI_GATEWAY_API_KEY=\"xyz\"\n```\n\n## \n\nYou’ll want a plain old API endpoint to send your AI prompts to, even if you’re using TanStack Start, which I am; we’ll use Server Routes rather than Server Functions.\n\nHere’s the simplest possible endpoint for sending AI prompts over to the model of our choice.\n\n``` js\nimport { chat, toJsonResponse } from \"@tanstack/ai\";\nimport { vercelGatewayText } from \"@tanstack/ai-vercel-gateway\";\nimport { createFileRoute } from \"@tanstack/react-router\";\n\nexport const Route = createFileRoute(\"/api/ai/chat-no-streaming\")({\n  server: {\n    handlers: {\n      POST: async ({ request }) => {\n        const { messages } = await request.json();\n\n        const stream = await chat({\n          adapter: vercelGatewayText(\"anthropic/claude-opus-5\"),\n          messages,\n          stream: false,\n        });\n\n        return toJsonResponse(stream);\n      },\n    },\n  },\n});\n```\n\nLet’s see how to process that on the front end.\n\n## \n\nUnsurprisingly, TanStack ships bindings for most UI frameworks. Since I’m using TanStack Start, I’ll of course use the React bindings.\n\n``` js\nimport { fetchJson, useChat } from \"@tanstack/ai-react\";\n```\n\nWe get a hook, and then an adapter for responses. Let’s fire it up.\n\n```\nconst { messages, sendMessage, isLoading } = useChat({\n  connection: fetchJson(\"/api/ai/chat-no-streaming\"),\n});\n```\n\nIt couldn’t be simpler. We have an array of current messages, a function to send a new prompt, and an `isLoading` indicator. Let’s wire up a basic UI (or have our agent do it).\n\n```\nfunction BasicChat() {\n  const [prompt, setPrompt] = useState(\"\");\n\n  const { messages, sendMessage, isLoading } = useChat({\n    connection: fetchJson(\"/api/ai/chat-no-streaming\"),\n  });\n\n  // ...\n\n  return (\n    <div>\n      <h1>Basic Chat</h1>\n\n      <div>\n        {messages.map(message =>\n          message.role === \"user\" ? (\n            <div key={message.id}>\n              {message.parts.map((part, index) => (part.type === \"text\" ? <p key={index}>{part.content}</p> : null))}\n            </div>\n          ) : (\n            <div key={message.id} className=\"w-full\">\n              {message.parts.map((part, index) => (part.type === \"text\" ? <p key={index}>{part.content}</p> : null))}\n            </div>\n          ),\n        )}\n        {isLoading && <Loader2 />}\n      </div>\n      {/* ... */}\n    </div>\n  );\n}\n```\n\nMessages have a role, and we format user prompts on the right in a nice blue bubble, since I lack the creative originality to think of a better UI than what ChatGPT does.\n\n### \n\nAnd now we can send a basic prompt and get a response.\n\nAnd of course you can keep the conversation going. Our back end from before already takes the existing messages from the thread, and passes them along.\n\n``` js\nconst { messages } = await request.json();\n\nconst stream = chat({\n  adapter: vercelGatewayText(\"anthropic/claude-opus-5\"),\n  messages,\n});\n```\n\nThe `useChat` hook will do the work of forwarding those messages. We can test this easily by giving a follow-up prompt that’s meaningless without the prior messages.\n\n## \n\nIt’s not ideal for our UI to wait until the entire message returns before showing anything. Anyone who’s used ChatGPT has seen AI models *stream* responses, so you can start reading immediately while the model streams the rest of the message in.\n\nLet’s add that here. The changes we have to make are surprisingly trivial.\n\nHere’s our new back-end endpoint.\n\n``` js\nimport { chat, toServerSentEventsResponse } from \"@tanstack/ai\";\nimport { vercelGatewayText } from \"@tanstack/ai-vercel-gateway\";\nimport { createFileRoute } from \"@tanstack/react-router\";\n\nexport const Route = createFileRoute(\"/api/ai/chat-streaming\")({\n  server: {\n    handlers: {\n      POST: async ({ request }) => {\n        const { messages } = await request.json();\n\n        const stream = chat({\n          adapter: vercelGatewayText(\"anthropic/claude-opus-5\"),\n          messages,\n        });\n\n        return toServerSentEventsResponse(stream);\n      },\n    },\n  },\n});\n```\n\nNote that we *removed* `stream: false` and changed our return value to this:\n\n```\nreturn toServerSentEventsResponse(stream);\n```\n\nTanStack gives us everything we need to establish an SSE<sup>[1](#6bfb8c72-3b70-46bb-abca-90b8d5cf9de5)</sup> stream that pipes our prompt results to the front end as it’s generated.\n\nOn the front end, we’ll grab a new import.\n\n``` js\nimport { fetchServerSentEvents, useChat } from \"@tanstack/ai-react\";\n```\n\nThen we’ll tweak our hook like so:\n\n```\nconst { messages, sendMessage, isLoading } = useChat({\n  connection: fetchServerSentEvents(\"/api/ai/chat-streaming\"),\n});\n```\n\nIt couldn’t be simpler. And now our UI streams.\n\n## \n\nIf we refresh the page, our prompt and responses vanish into the void; nothing saves any of it anywhere.\n\nLet’s fix that and add persistence.\n\nFirst, we need a new package.\n\n```\nnpm i @tanstack/ai-persistence\n```\n\nTanStack AI handles persistence a bit differently than you might expect. It gives you a contract to satisfy however you’d like, in whatever database you want. And of course you’re not expected to manually cobble together the needed schema definitions via DDL.\n\nTanStack AI actually gives you an [AI Skill to install](https://tanstack.com/ai/latest/docs/persistence/build-your-own-adapter#let-your-agent-write-it), which should generate all of the needed code. In fact, it’s even aware of Drizzle, and will happily generate the needed Drizzle schema objects, allowing you to just `npx drizzle-kit push` to generate the tables in your actual database. Or it can generate the needed stores against a raw database.\n\nHere’s a sample of the Drizzle-based persistence module it one-shotted for me.\n\n``` js\nimport { and, asc, desc, eq, isNotNull, lte } from \"drizzle-orm\";\nimport { defineAIPersistence } from \"@tanstack/ai-persistence\";\nimport type { SQL } from \"drizzle-orm\";\nimport type { ChatPersistence, InterruptRecord, InterruptStore, MessageStore, MetadataStore, RunRecord, RunStore } from \"@tanstack/ai-persistence\";\n\nimport { chatInterrupts, chatMetadata, chatRuns, chatThreads } from \"#/drizzle/schema\";\nimport { db, type DB } from \"#/data/db\";\n\n// Records omit absent optionals so they compare cleanly against the reference\n// in-memory back end.\nfunction mapRun(row: typeof chatRuns.$inferSelect): RunRecord {\n  return {\n    runId: row.runId,\n    threadId: row.threadId,\n    status: row.status,\n    startedAt: row.startedAt,\n    ...(row.finishedAt != null ? { finishedAt: row.finishedAt } : {}),\n    ...(row.error != null\n      ? {\n          error: {\n            message: row.error,\n            ...(row.errorCode != null ? { code: row.errorCode } : {}),\n          },\n        }\n      : {}),\n    ...(row.usageJson != null ? { usage: row.usageJson } : {}),\n    ...(row.sandboxKey != null ? { sandboxKey: row.sandboxKey } : {}),\n    ...(row.detachedSince != null ? { detachedSince: row.detachedSince } : {}),\n    ...(row.cancelRequested != null ? { cancelRequested: row.cancelRequested } : {}),\n    ...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),\n    ...(row.parentRunId != null ? { parentRunId: row.parentRunId } : {}),\n    ...(row.subagentRunId != null ? { subagentRunId: row.subagentRunId } : {}),\n    ...(row.name != null ? { name: row.name } : {}),\n  };\n}\n\nfunction mapInterrupt(row: typeof chatInterrupts.$inferSelect): InterruptRecord {\n  return {\n    interruptId: row.interruptId,\n    runId: row.runId,\n    threadId: row.threadId,\n    status: row.status,\n    requestedAt: row.requestedAt,\n    payload: row.payloadJson,\n    ...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}),\n    ...(row.responseJson != null ? { response: row.responseJson } : {}),\n  };\n}\n\nfunction createMessageStore(db: DB): MessageStore {\n  // ....\n}\n\n// ...\n\n/** The four chat state stores backed by the app's Drizzle database. */\nexport const persistence: ChatPersistence = defineAIPersistence({\n  stores: {\n    messages: createMessageStore(db),\n    runs: createRunStore(db),\n    interrupts: createInterruptStore(db),\n    metadata: createMetadataStore(db),\n  },\n});\n```\n\nIf using an AI skill to generate code that lives in your repo for you to tweak seems crazy, just remember: substitute “CLI” for “AI skill,” and you’re basically left with ShadCN.\n\nThat said, I don’t think this current AI skill is the final form of persistence code generation for TanStack AI, and I’d love to see it replaced with a proper CLI. But for a new project, this is an outstanding solution for now.\n\nLet’s put this persistence code to good use!\n\n### \n\nStep one is adding our new persistence store as middleware on the server. I know I haven’t covered middleware yet, and won’t be for this post, but TanStack AI supports a full middleware chain for processing, modifying, or, in this case, persisting AI threads. We’ll add it in our server route.\n\nWe’ll also forward along any `threadId` or `runId` passed from the frontend. This will allow the frontend to request a persisted thread, or even resume an interrupted thread—more on that soon.\n\n``` js\nimport { chat, chatParamsFromRequest, toServerSentEventsResponse } from \"@tanstack/ai\";\nimport { reconstructChat, withPersistence } from \"@tanstack/ai-persistence\";\n\n    POST: async ({ request }) => {\n      const params = await chatParamsFromRequest(request);\n\n      const stream = chat({\n        adapter: vercelGatewayText(\"anthropic/claude-opus-5\"),\n        messages: params.messages,\n        threadId: params.threadId,\n        runId: params.runId,\n        middleware: [withPersistence(persistence)],\n      });\n\n      return toServerSentEventsResponse(stream);\n    },\n```\n\n### \n\nOn the front end, we need to send over a `threadId` and tell our hook that we’re using persistence.\n\n```\nconst { messages, sendMessage, isLoading } = useChat({\n  connection: fetchServerSentEvents(\"/api/ai/chat-with-persistence\"),\n  persistence: true,\n  threadId: \"123\",\n});\n```\n\nFor a real app, we’d generate a meaningful (and unique!) `threadId`, but for now, “123” will work just fine. Now when we run a prompt and get results, we can check our database and see our threads being saved!\n\nBut when we refresh, our page is empty. Why isn’t the saved thread loading in the UI?\n\n## \n\nWhatever back-end endpoint we set up for our prompts is a POST, which TanStack AI uses to submit a new prompt. To load a saved thread, we need to set up a GET handler at the same place, and use TanStack’s helpers to load the thread in question (the front end will include the `threadId` with the request).\n\n``` js\nimport { reconstructChat, withPersistence } from \"@tanstack/ai-persistence\";\n\n  GET: async ({ request }) => {\n    return reconstructChat(persistence, request, {\n      // WITHOUT this, anyone who guesses a thread id gets the whole transcript.\n      authorize: async (threadId, req) => true,\n    });\n  },\n```\n\nYou should fill in the `authorize` callback with actual verification logic, but otherwise, with that, reloading the page re-renders the same thread you just saw above.\n\nIf you’d like to render a loading indicator while the existing thread is being loaded from persistence, use the `isHydrating` boolean returned from the `useChat` hook.\n\n## \n\nWhat happens if we refresh the page *while* the response is being generated? Right now, that disconnects the SSE stream, and we lose our results completely. To fix this, we need to make our response stream resumable by buffering results into an in-memory stream on the server, then checking that in the GET endpoint before returning what’s in our database.\n\nUnsurprisingly, TanStack makes this easy.\n\nFirst we’ll modify our POST handler like this:\n\n``` js\nimport { chat, chatParamsFromRequest, toServerSentEventsResponse, memoryStream, resumeServerSentEventsResponse } from \"@tanstack/ai\";\n\n  POST: async ({ request }) => {\n    const params = await chatParamsFromRequest(request);\n\n    const stream = chat({\n      adapter: vercelGatewayText(\"anthropic/claude-opus-5\"),\n      messages: params.messages,\n      threadId: params.threadId,\n      runId: params.runId,\n      middleware: [withPersistence(persistence)],\n    });\n\n    return toServerSentEventsResponse(stream, {\n      durability: { adapter: memoryStream(request) },\n    });\n  },\n```\n\nThat buffers our output into a memory stream. And now we can consult that memory stream when loading a thread in our GET handler.\n\n``` js\n  GET: async ({ request }) => {\n    const durability = memoryStream(request);\n    if (durability.resumeFrom() !== null) {\n      return resumeServerSentEventsResponse({ adapter: durability });\n    }\n\n    return reconstructChat(persistence, request, {\n      // WITHOUT this, anyone who guesses a thread id gets the whole transcript.\n      authorize: async (threadId, req) => true,\n    });\n  },\n```\n\nBe sure to repeat whatever security check you (hopefully) added in the `authorize` callback before resuming anything from that memory stream.\n\nIt’s surprisingly little boilerplate, and it’s incredibly flexible if you ever want to tweak anything.\n\nAnd now, when we refresh the page mid-response, it resumes where it left off and keeps going.\n\nFor this demo, we’re using an in-memory stream, which works great locally (or when connecting to something like a Cloudflare Durable Object with a consistent identity). In production, you may want a durable stream back end so that resuming works across server instances and restarts.\n\n## \n\nTanStack AI is an exciting addition to the growing list of AI tools. We set up basic prompting with persistence and resumability with little effort. And yet we’ve barely scratched the surface of what this library can do.\n\nStay tuned for part two, where we’ll dive into structured output, and future posts where we’ll go even deeper.\n\n1. **Server-Sent Events** : they’re like WebSockets, but only one way, from the server to the client.\n [↩︎](#6bfb8c72-3b70-46bb-abca-90b8d5cf9de5-link)", "url": "https://wpnews.pro/news/unlocking-ai-in-your-apps-with-tanstack-ai", "canonical_source": "https://blog.master.dev/unlocking-ai-in-your-apps-with-tanstack-ai/", "published_at": "2026-10-16 12:32:57+00:00", "updated_at": "2026-10-16 12:45:56.249006+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "generative-ai", "ai-products"], "entities": ["TanStack", "TanStack AI", "@tanstack/ai", "@tanstack/ai-react", "@tanstack/ai-vercel-gateway", "Vercel AI Gateway", "TanStack Start", "Anthropic Claude Opus 5"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/unlocking-ai-in-your-apps-with-tanstack-ai", "markdown": "https://wpnews.pro/news/unlocking-ai-in-your-apps-with-tanstack-ai.md", "text": "https://wpnews.pro/news/unlocking-ai-in-your-apps-with-tanstack-ai.txt", "jsonld": "https://wpnews.pro/news/unlocking-ai-in-your-apps-with-tanstack-ai.jsonld"}}