Streaming Structured Output into a Datastar UI (a Card That Fills Itself In)
So far in this series we've streamed text — the primitive, a summarizer, a chat widget, and an agent with tools. But a lot of the most useful AI features aren't freeform text at all — they're structured. A recipe with a title, ingredients, and steps. A parsed invoice. A set of generated options. And the magic trick that makes those feel fast is streaming the structure as it generates, so the UI fills itself in field by field instead of popping in all at once after a long pause.
This is where people used to reach for streamObject. In the Vercel AI SDK v6 that helper is deprecated — the new home for structured generation is streamText with an output specification. Let's use it to build a recipe card that writes itself into the page live: title first, then the ingredients as they're decided, then the steps. (On brand for me — I once built a whole AI recipe generator.)
We'll reuse the datastarResponse helper from the first post.
Describing the shape
Structured output starts with a schema. We use Zod, and the AI SDK validates the model's output against it.
// src/lib/schemas.ts
import { z } from "zod";
export const recipeSchema = z.object({
title: z.string().describe("A short, appealing recipe name"),
servings: z.number().describe("Number of servings"),
ingredients: z.array(z.string()).describe("Each ingredient with quantity"),
steps: z.array(z.string()).describe("Ordered preparation steps"),
});
Those .describe() calls aren't decoration — they're instructions the model reads to fill each field correctly. Treat them like a spec.
The endpoint
Here's the structured-streaming endpoint. The key piece is experimental_output: Output.object(...), and then iterating the partial output stream — which hands you the object progressively, more complete with each chunk.
// src/pages/api/recipe.ts
import type { APIRoute } from "astro";
import { streamText, Output } from "ai";
import { openai } from "@ai-sdk/openai";
import { datastarResponse } from "../../lib/datastar";
import { recipeSchema } from "../../lib/schemas";
export const maxDuration = 30;
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const ingredients = body?.ingredients ?? "";
return datastarResponse(async ({ patchElements, patchSignals, close }) => {
patchSignals({ cooking: true });
const result = streamText({
model: openai("gpt-5.5"),
experimental_output: Output.object({ schema: recipeSchema }),
prompt: `Create a recipe using mainly: ${ingredients}`,
abortSignal: request.signal,
});
// each chunk is the object so far — fields appear as they're generated
for await (const partial of result.experimental_partialOutputStream) {
patchElements(renderCard(partial));
}
patchSignals({ cooking: false });
close();
});
};
function renderCard(r: Partial<{
title: string;
servings: number;
ingredients: string[];
steps: string[];
}>) {
const esc = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
return `<div id="recipe" class="recipe-card">
<h2>${r.title ? esc(r.title) : "…"}</h2>
${r.servings ? `<p class="servings">Serves ${r.servings}</p>` : ""}
${
r.ingredients?.length
? `<ul>${r.ingredients.map((i) => `<li>${esc(i)}</li>`).join("")}</ul>`
: ""
}
${
r.steps?.length
? `<ol>${r.steps.map((s) => `<li>${esc(s)}</li>`).join("")}</ol>`
: ""
}
</div>`;
}
The whole effect lives in that loop. experimental_partialOutputStream yields the object again and again as it builds — first maybe just { title }, then { title, servings }, then ingredients start filling the array, then steps. Each time, we re-render the whole card and let Datastar morph it. The user watches the recipe assemble itself: the title lands, "Serves 4" appears, ingredients tick in one by one, then the steps. It feels alive.
A naming heads-up. As I write this, the option is
experimental_outputand the stream isresult.experimental_partialOutputStream— theexperimental_prefix is real, and v6 is in the middle of promoting these toward stableoutput/partialOutputStream(the v6Agentalready takes a plainoutput:). If the experimental names don't resolve in your installed version, drop the prefix and check the AI SDK docs for your exact version. The pattern is stable even while the names settle.
Because partial objects are, by definition, incomplete, they aren't validated against your schema mid-stream — a field might be missing or half-formed on any given chunk. That's why renderCard guards every field with optional chaining and fallbacks. Never assume a partial is complete until the stream ends.
The page
Same Datastar shape as everything else in this series.
---
import Layout from "../layouts/Layout.astro";
---
<Layout>
<div data-signals="{ingredients: '', cooking: false}">
<input data-bind:ingredients placeholder="chicken, lemon, thyme..." />
<button
data-on:click="@post('/api/recipe')"
data-attr:disabled="$cooking"
>
<span data-show="!$cooking">Generate recipe</span>
<span data-show="$cooking">Cooking...</span>
</button>
<div id="recipe"></div>
</div>
</Layout>
Type "chicken, lemon, thyme," hit the button, and a recipe card builds itself into the page in real time. No client framework, no useObject hook (which is React-only anyway), no waiting for the whole JSON blob to finish before showing anything.
When you want a list of things
The partial-object approach is perfect for one rich object filling in. When you instead want a collection — five recipe ideas, a list of options, search results — there's an even cleaner tool: Output.array(). Stream it and you can iterate complete, validated elements one at a time as each finishes:
const result = streamText({
model: openai("gpt-5.5"),
experimental_output: Output.array({ schema: recipeSchema }),
prompt: `Suggest 5 recipes using: ${ingredients}`,
});
for await (const recipe of result.experimental_elementStream) {
// each `recipe` here is COMPLETE and schema-validated
patchElements(
`<div id="ideas" data-append>${renderCard(recipe)}</div>`
);
}
This pairs beautifully with Datastar's data-append: each finished recipe gets appended to the list as a complete card, so the page fills with cards one satisfying pop at a time. Unlike the partial-object stream, every element here is fully formed and validated — so no defensive optional chaining needed on the elements themselves.
The pattern, one more time
It's the same four beats as every post in this series, just with structured data instead of text:
- A Datastar action posts your signals.
- The endpoint calls
streamText— this time with anoutputspec. - Each partial (or each complete element) gets rendered to HTML and emitted as an SSE patch.
- Datastar morphs it in. No client code.
Text streaming, agents with tools, structured output — they're all the same pipe. You iterate what the AI SDK gives you on the server, and you hand Datastar HTML. That's the entire integration, and it covers basically every AI UI you'd want to build.
In the last post of the series we'll do something a little different: take this exact stack and run it entirely locally with Ollama — no API key, no per-token bill, your data never leaving your machine — by changing a single import.