Route handler and useChat with Zod schemas

The agent starts as a plain streaming chat. Browser sends messages to a POST route, the route calls streamText, and the response streams back as UI messages. Once this baseline works, every other piece we add (tools, reasoning, memory) plugs into this same stream.

app/api/chat/route.ts
typescript
import { streamText, convertToModelMessages } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';

import { SYSTEM_PROMPT } from '@/lib/utils';

export const maxDuration = 60;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const openrouter = createOpenRouter({
    apiKey: process.env.OPENROUTER_API_KEY ?? '',
  });

  const result = streamText({
    model: openrouter.chat(
      process.env.OPENROUTER_MODEL ?? 'google/gemma-3-12b-it',
    ),
    system: SYSTEM_PROMPT,
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

streamText opens a model stream. toUIMessageStreamResponse turns it into the exact protocol useChat understands on the client. No custom parsing needed.

components/chat/chat-panel.tsx
tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { useState } from 'react';

export function ChatPanel() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status } = useChat({
    api: '/api/chat',
  });

  const isLoading = status === 'submitted' || status === 'streaming';

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        if (!input.trim()) return;
        sendMessage({ text: input });
        setInput('');
      }}
    >
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {JSON.stringify(m.parts)}
        </div>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button type="submit" disabled={isLoading}>Send</button>
    </form>
  );
}

useChat gives you messages, sendMessage, and a status flag. Each message has a parts array so text and tool invocations can sit inside one message. We will render them properly once tools arrive.

lib/schemas.ts
typescript
import { z } from 'zod';

// The request body the route handler accepts from useChat
export const chatRequestSchema = z.object({
  messages: z.array(
    z.object({
      id: z.string(),
      role: z.enum(['user', 'assistant', 'system']),
      parts: z.array(z.object({ type: z.string() }).passthrough()),
    }),
  ),
});

export type ChatRequest = z.infer<typeof chatRequestSchema>;

Zod is the shared contract between client and server. Parse req.json() with this schema and malformed payloads fail before they reach the model.

Quiz: Quiz

Loading practiceโ€ฆ