Input validation and output schema

Guardrails live at the seams. The inbound seam is where the user talks to your route handler: parse the body with Zod, cap message length, reject anything suspicious. The outbound seam is where the model talks back: when you need structured data, use generateObject with a Zod schema so the model cannot ship malformed JSON.

app/api/chat/route.ts
typescript
import { chatRequestSchema } from '@/lib/schemas';

export async function POST(req: Request) {
  const body = await req.json();
  const parsed = chatRequestSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { error: 'Invalid request', issues: parsed.error.issues },
      { status: 400 },
    );
  }

  const lastUser = parsed.data.messages.at(-1);
  const userText = (lastUser?.parts ?? [])
    .filter((p) => p.type === 'text')
    .map((p: { type: string; text?: string }) => p.text ?? '')
    .join('');

  if (userText.length > 2_000) {
    return Response.json(
      { error: 'Message too long. Keep it under 2000 characters.' },
      { status: 413 },
    );
  }

  // ... streamText call follows
}

Two input guardrails: Zod shape validation and a length cap. Anything that does not fit bounces with a 4xx before the LLM spins up.

lib/tools/generate-summary.ts
typescript
import { generateObject } from 'ai';
import { z } from 'zod';

const summarySchema = z.object({
  title: z.string().max(80),
  bullets: z.array(z.string().max(120)).min(3).max(5),
  confidence: z.number().min(0).max(1),
});

export async function summarizeSearchResults(text: string) {
  const { object } = await generateObject({
    model: getModel(),
    schema: summarySchema,
    prompt: `Summarize this web page for a busy engineer:\n\n${text}`,
  });
  return object;
}

generateObject forces the model to produce JSON that fits the Zod schema. If it cannot, the SDK retries internally. Your downstream code receives a fully typed object or a thrown error, never malformed JSON.

Quiz: Quiz

Loading practice…