Skip to content
July 31, 2026Article

AI SDK 7 Crash Course: Build a Production Chat UI

How one text call grows into a typed, streaming, multimodal, tool-using chat route, and why AI SDK 7 gives you one contract to build all of it on.

Back to Blog

Almost every AI feature starts the same way. You call a model, you get text back, and you ship the demo. It works. It even feels a little magical the first time the answer appears.

Then the app grows. A better model ships, and it happens to come from a different provider. So you swap it in. But it is never just the model name. The import changes. The client changes. The method changes. And the response shape changes too, so now you are reaching into a different object just to pull out the same string you already had. The UI still wants exactly what it wanted before. The code suddenly has to relearn where that thing lives.

That’s the problem the AI SDK is trying to solve. It gives you a set of tools and functions that unify how your app works with different AI providers, so the rest of your application does not have to keep changing every time the provider or model changes.

The provider can change underneath you. The way your app works does not.

The AI SDK team moves quickly, so check the latest docs (opens in new tab) before you copy anything straight into production.

1. One question, three different APIs

Start with the simplest case: ask a model one question and get an answer. Easy.

The friction begins when the same feature needs to work across OpenAI, Anthropic, and Google. Switching providers changes the import, client, method, and response shape; it is not just the model name.

It is manageable once, but it becomes a recurring tax whenever you compare models or change providers.

2. One contract over all of them

This is the shift. The AI SDK gives your app one stable contract to build against and pushes provider differences underneath it.

You still choose the provider and model. That choice stays yours. But the rest of the app can use the same functions and response shapes rather than learning a new client every time.

In code, that means OpenAI and Anthropic can sit behind a single app-level model function. Your route never has to know which client got created, or which provider-specific response shape came back. The provider is a detail now, not a rewrite.

The model can change. The contract stays still.

3. Install only what the first call needs

Start tiny. Install only what the first call actually needs: the core SDK, the React hooks package, one provider package, and Zod.

Add the second provider only when you want to test switching. Keeping it this small makes the first call easier to plan and leaves you less setup to debug.

4. Give the provider its key

Create a file named .env.local at the project root, then add the API key for the provider you choose. The provider reads it at runtime.

touch .env.local

For example, if you choose OpenAI:

OPENAI_API_KEY=...

If you choose another provider, use that provider's API-key environment variable instead.

5. Make your first call with generateText

The first function to learn is generateText. Use it when nobody needs to watch the answer arrive, or when your code needs the complete result before it can move on.

You give it a model and a prompt. You get back the final text and total usage. Model in, result out. That is the whole first call.

Now imagine that prompt comes from someone on their phone, asking a long support question. With generateText, nothing appears until the full answer is ready. That is fine for a background job. In a chat, it can feel like the app froze.

That visible wait is why Vercel introduced streamText.

6. Keep provider choice in one place

The SDK makes switching providers easier, but your app still needs one clear place that owns that choice as the codebase grows.

Put model selection in one file. That file can know about providers and setup. Everywhere else, the app just calls appModel() and gets the same output shape back.

That is the part that matters. Provider setup stays behind one boundary, while the rest of the app stays built on the AI SDK contract. When a better model arrives, you change one file, not the whole app.

It is also your safety boundary. requireEnv tells you exactly which key is missing instead of giving you a vague error halfway through a request.

Keep it explicit. Minimal on purpose. Safe by default.

7. Stream when the wait becomes visible

The moment a person is waiting on an answer, streamText becomes useful. Think chat, writing assistants, copilots, or a long live summary.

The code barely moves. Call streamText, then loop over result.textStream. What changes is the experience: the answer can form word by word instead of leaving someone staring at a spinner and wondering whether anything is happening.

The rule is simple. Use generateText for work nobody is watching: a background job that summarizes a document, classifies support tickets, drafts a report, or creates an artifact for another step in your system. The app can wait for the result because no person is waiting for it.

Use streamText when a person is waiting in front of the screen: a chatbot, a writing assistant, a copilot, or a live summary. In those moments, seeing the answer arrive is part of the product.

generateText is for completed work. streamText is for visible work.

8. See what streaming changes in the UI

The code is only half the idea. The other half is what the person on the other end feels.

In this moment, someone asks a long, mobile-style question, and the answer fills into a phone-sized chat UI as streamed message parts. Seeing it land that way is what turns streamText from "an API I chose" into "a product behavior I shipped." That is the whole point of stopping to look at it here.

9. Turn that stream into a chat response

The browser runs on the client. Anything it needs can be exposed to the person using the app, so it is not the place to keep a provider API key or make the server-side model call.

That call stays on the server, where your secrets and controls are protected. The route is the small bridge between the browser and that server-side work.

It receives the UI messages, turns them into model-ready messages with convertToModelMessages, calls streamText, then sends back a UI message stream.

That boundary keeps provider details and secrets out of React. It also gives the server one clear place to add authentication, rate limits, logging, and other production controls later.

10. See the route do its job

The request goes into /api/chat, the model stream starts, and the UI begins receiving message parts. Nothing mysterious is happening: the route is translating between the browser and the model.

11. Let useChat manage the chat state

Now the problem is keeping browser state in sync with the server stream. useChat handles that work for you.

For the first version, render text from message.parts, send a message, and use status to keep the form honest while a response is underway. You do not need to build your own request, stream, and message-state machinery first.

The useful habit is to render the parts you receive instead of assuming every message is one string. Text is the first part type. More can arrive later without forcing you to redesign the whole chat.

12. Make the waiting state clear

This phone moment is deliberately simple: submitted, streaming, ready. Those states let you disable the send button and show that work is in progress, so the user never has to wonder whether the chat quietly died.

13. Add files without rebuilding the chat

Once chat works, someone will want to add an image. Handle that at the UI boundary, where the user actually is, not by building a separate chat architecture.

For image input in chat, the route does not need a separate image branch. Send text and files together, let the transport carry the parts, and let convertToModelMessages produce the model-facing shape.

For broader file types, add explicit handling. But for images in chat, it stays beautifully small.

14. An image becomes another message part

The client does not need a separate conversation model for images. It receives a file part, then text streams back through the same UI contract you already built.

Same loop, one more part type.

15. Render the part you received

Text parts become paragraphs. Image file parts become images. Other files can get download links, previews, or an honest unsupported-state fallback. Render the part you received instead of assuming the message is always plain text.

16. Ask for an object when prose is not enough

At some point, a paragraph stops being useful. Maybe the UI needs a title, a summary, and a list of actions in named fields. That is when structured output earns its place.

Use generateText with output: Output.object(...) to ask for that shape directly. Your UI gets fields it can render instead of prose it has to parse and hope is consistent.

17. Use the shape the UI actually needs

Use Output.array when the UI needs a validated list. Use Output.choice when the model must pick one fixed label such as a category, route, or intent. The schema becomes the contract, so the UI does not need to parse prose and hope the model phrased it consistently.

18. Stream an object when the UI can use it early

Sometimes the object is large enough that waiting for all of it recreates the same old latency problem. Structure does not have to wait for the end either.

partialOutputStream is a stream of incomplete versions of the object as it is being generated. The UI does not wait for every field to be ready. It can receive a title first, then bullets, then draft sections as those parts become usable.

That is useful when each part has value on its own. Instead of keeping an empty card on screen until everything is complete, the interface can start filling in as the structured response arrives.

19. See a structured response arrive

When you stream structure, the product can render fields the moment they exist. Here, the title and first bullet are already visible while the next bullet is still arriving. The user sees progress instead of an empty card.

20. Give the model one narrow capability

A chat can explain things, but sometimes it needs current information or a real action. That is the job of a tool.

Three pieces do the essential work. description tells the model when the tool is appropriate. inputSchema defines the exact input it accepts. execute is the whole function that performs the narrow action.

Start narrow. This tool looks up one order status. It does not hand the model a database client, arbitrary HTTP access, shell access, or broad customer mutation powers. A production tool should be small enough that you can describe its full authority in one sentence.

21. Let the model use the tool, then bound the loop

Tool calling is a loop, not a single call. The model asks for a tool, the SDK validates input against your schema, your execute function runs, and the result returns to the model.

The model may answer after that, or ask for another tool. stopWhen: stepCountIs(4) opts into that multi-step continuation and puts a ceiling on it. Pick a number that fits the real workflow, then test normal completion and tool-heavy edge cases. The cap protects the experience and your bill.

22. Show that the tool is working

When a tool runs, the user should not get a dead pause and wonder what broke. Show the transition from tool input to output so the tool call is part of the experience, not a gap they have to guess about.

23. Render each tool state explicitly

The UI should not have to guess what a tool part contains. Infer UI tool types from the server tool object, pass the message type into useChat, then render the states explicitly: input streaming, input available, output available, and output error. The UI and route agree by construction.

This matters because people can see what the app is doing, understand when something failed, and trust the conversation instead of wondering whether it broke.

24. Keep the production receipt on the server

Before you call the feature done, make it observable. Total usage and finish reasons are not a dashboard afterthought when they affect quality, cost, and debugging.

Use onFinish to log total usage, finish reason, and model metadata on the server. Send only UI-safe pieces back with messageMetadata, such as the final token count or finish reason. The server keeps the operational record; the UI gets the responsible slice.

Total usage is something you need in production, not something to discover when the bill arrives.

25. Show the user a safe summary

The server can hold the complete record behind the scenes. The phone only surfaces the finish reason and total token count: enough for the user to understand what happened, and nothing they were never meant to see.

What you have now

You started with one request. Then each new limitation gave you a reason for one new capability.

The model boundary stays small. You choose a model and call generateText or streamText; provider setup stays in one place.

The route boundary stays stable. It converts messages, adds tools, bounds loops, logs the result, and returns a UI message stream.

The UI renders message parts, file parts, tool states, and safe metadata from the same message contract, so it never has to guess what it is holding.

That is the difference between a demo chat box and a chat feature you can keep growing without dreading the next model swap.

Further reading

Official reference: AI SDK docs (opens in new tab).

Enjoyed this post?

Get notified when I publish something new. No spam, just fresh content.

Published July 31, 2026