Astro tutorial
In this tutorial, you’ll build the Astro UI for Bargain Chef and connect it to your existing Genkit backend. It uses two AI patterns Genkit simplifies: streaming structured output and tool calling.
What you’ll build
Section titled “What you’ll build”The user types what they’re craving, Gemini drafts a recipe, and the model calls a tool to look up mock grocery sale prices so it can prefer on-sale ingredients. The recipe streams into the UI incrementally, so users see progress before the full recipe is ready.
You can find the finished code on GitHub.
You’ll call the existing bargainChefFlow over HTTP and render the streamed recipe as fields arrive.
Prerequisites
Section titled “Prerequisites”- Node.js v20 or later
- npm
- Familiarity with Astro and TypeScript
You should have already completed the Shelf or other backend tutorial. This tutorial picks up where those leave off and adds an Astro UI to the bargainChefFlow you already built there. Make sure your backend is running and note the port it serves on, since you’ll need it later.
Set up the application
Section titled “Set up the application”Create the Astro project:
npm create astro@latest my-genkit-astrocd my-genkit-astropnpm create astro@latest my-genkit-astrocd my-genkit-astroyarn create astro@latest my-genkit-astrocd my-genkit-astrobun create astro@latest my-genkit-astrocd my-genkit-astroWhen prompted, choose the Empty template and enable TypeScript.
Install the Genkit web client, which lets the browser call your backend:
npm install genkitpnpm add genkityarn add genkitbun add genkitYour backend should already expose bargainChefFlow. For reference, here’s the shape the Astro app expects:
// Input{ craving: string }
// Streamed output (partial: fields fill in over time){ title?: string; description?: string; servings?: number; ingredients?: { name: string; quantity: string; onSale: boolean }[]; steps?: string[];}If your backend exposes a flow with a different name or shape, adjust the URL and the Recipe interface in the next section accordingly.
At this point, your Astro app is ready to call the backend flow you already created.
Build the Astro UI
Section titled “Build the Astro UI”Now update the Astro page so the browser can call your Genkit backend and render streamed output. Astro pages can host UI in framework islands (React, Svelte, Vue, and others), but for this tutorial we keep it simple with plain HTML and a single TypeScript <script> block that Astro bundles for the browser.
Add the page script
Section titled “Add the page script”You’ll drop the following script into src/pages/index.astro in the next step. The URL points to your standalone backend; 8080 is a common default backend port, so adjust it if your backend uses a different port.
import { streamFlow } from 'genkit/beta/client';
// Point this at the URL where your bargainChefFlow is servedconst FLOW_URL = 'http://localhost:8080/bargainChefFlow';
interface RecipeIngredient { name?: string; quantity?: string; onSale?: boolean;}
interface Recipe { title?: string; description?: string; servings?: number; ingredients?: RecipeIngredient[]; steps?: string[];}
const form = document.getElementById('prompt-form') as HTMLFormElement;const cravingInput = document.getElementById('craving') as HTMLInputElement;const submitButton = document.getElementById('submit') as HTMLButtonElement;const recipeEl = document.getElementById('recipe') as HTMLElement;
let isStreaming = false;
function setStreaming(value: boolean) { isStreaming = value; cravingInput.disabled = value; submitButton.disabled = value; submitButton.textContent = value ? 'Cooking…' : 'Suggest a recipe';}
function render(recipe: Recipe | null) { if (!recipe) { recipeEl.innerHTML = ''; return; }
const parts: string[] = ['<article>']; if (recipe.title) parts.push(`<h2>${recipe.title}</h2>`); if (recipe.description) parts.push(`<p class="description">${recipe.description}</p>`); if (recipe.servings) parts.push( `<p class="serves"><strong>Serves:</strong> ${recipe.servings}</p>`, );
if (recipe.ingredients?.length) { parts.push('<h3>Ingredients</h3><ul class="ingredients">'); for (const ing of recipe.ingredients) { const badge = ing.onSale ? ' <span class="badge">on sale</span>' : ''; parts.push(`<li>${ing.quantity ?? ''} ${ing.name ?? ''}${badge}</li>`); } parts.push('</ul>'); }
if (recipe.steps?.length) { parts.push('<h3>Steps</h3><ol class="steps">'); for (const step of recipe.steps) { parts.push(`<li>${step}</li>`); } parts.push('</ol>'); }
parts.push('</article>'); recipeEl.innerHTML = parts.join('');}
form.addEventListener('submit', async (event) => { event.preventDefault(); const craving = cravingInput.value.trim(); if (!craving) return;
render(null); setStreaming(true); try { const result = streamFlow({ url: FLOW_URL, input: { craving }, }); for await (const partial of result.stream) { render(partial as Recipe); } await result.output; } catch (err) { console.error('Failed to generate recipe', err); } finally { setStreaming(false); }});streamFlow returns an async iterable of partial recipe objects. Each chunk is the accumulated structured output so far, with fields such as title, ingredients, and steps filling in as the model generates them. The script re-renders the recipe section on every chunk so the UI fills in progressively.
Update the page
Section titled “Update the page”Replace the contents of src/pages/index.astro with the following. The <script> block holds the code from the previous step (Astro compiles it and bundles it for the browser):
---// No server-side logic needed for this page.---
<html lang="en"> <head> <meta charset="utf-8" /> <title>Bargain Chef</title> <link rel="stylesheet" href="/styles.css" /> </head> <body> <main> <h1>Bargain Chef</h1> <p class="tagline"> Tell me what you feel like eating and I'll suggest a recipe built around today's grocery deals. </p>
<form id="prompt-form" class="prompt"> <input id="craving" type="text" name="craving" value="something warm with chicken" placeholder="What are you in the mood for?" /> <button id="submit" type="submit">Suggest a recipe</button> </form>
<div id="recipe"></div> </main>
<script> // Paste the script from the previous step here. </script> </body></html>Each recipe section is only rendered after that field arrives in the stream. The result is a UI that fills in progressively: title first, then description, then ingredients, then steps.
Wrapping the input and button in a <form> lets the user submit by pressing Enter. The submit handler calls preventDefault() so the browser doesn’t reload the page, then kicks off the streaming request.
Add styles
Section titled “Add styles”Create public/styles.css with the following:
:root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; background: #fafafa;}
body { margin: 0; min-height: 100vh; padding: 3rem 1.5rem;}
main { max-width: 640px; margin: 0 auto;}
h1 { font-size: 2rem; margin: 0 0 0.25rem; letter-spacing: -0.01em;}
.tagline { color: #555; margin: 0 0 2rem;}
.prompt { display: flex; gap: 0.5rem; margin-bottom: 2.5rem;}
.prompt input { flex: 1; font: inherit; font-size: 1rem; padding: 0.75rem 1rem; border: 1px solid #d0d0d0; border-radius: 8px; background: #fff; transition: border-color 120ms ease, box-shadow 120ms ease;}
.prompt input:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);}
.prompt input:disabled { background: #f1f1f1; color: #888;}
.prompt button { font: inherit; font-size: 1rem; font-weight: 500; padding: 0.75rem 1.25rem; border: 0; border-radius: 8px; background: #1a1a1a; color: #fff; cursor: pointer; transition: background 120ms ease; white-space: nowrap;}
.prompt button:hover:not(:disabled) { background: #2563eb;}
.prompt button:disabled { background: #999; cursor: not-allowed;}
article { background: #fff; border: 1px solid #e5e5e5; border-radius: 12px; padding: 1.5rem 1.75rem;}
article h2 { font-size: 1.5rem; margin: 0 0 0.5rem;}
article h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.06em; color: #666; margin: 1.5rem 0 0.5rem;}
.description { color: #444; margin: 0 0 1rem;}
.serves { color: #555; margin: 0; font-size: 0.95rem;}
.ingredients,.steps { padding-left: 1.25rem; line-height: 1.6;}
.ingredients li { margin-bottom: 0.25rem;}
.steps li { margin-bottom: 0.5rem;}
.badge { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.5rem; font-size: 0.75rem; font-weight: 500; background: #e8f5e9; color: #2e7d32; border-radius: 999px;}
@media (max-width: 480px) { .prompt { flex-direction: column; } .prompt button { width: 100%; }}Run the app
Section titled “Run the app”Start your Genkit backend in one terminal by following the run instructions in the backend tutorial you used. Then start the Astro development server in another terminal:
npm run devpnpm run devyarn devbun run devOpen http://localhost:4321, enter a craving like something warm with chicken, and submit. The recipe streams in field by field: title first, then description, then ingredients (with “on sale” badges on the ones the model picked from the tool), then steps.
If the request fails, check the browser console first. The most common issue is a CORS error or a backend URL that doesn’t match the route in your page script.
Test and inspect the app
Section titled “Test and inspect the app”The Genkit Developer UI is a local console for testing flows and inspecting traces. It records every tool call, model invocation, and streamed chunk, so you can see what the model called, what it received back, and how the recipe was assembled.
If your backend is running under genkit start, the Developer UI is already running at http://localhost:4000.
In the Developer UI:
- The Traces tab shows every invocation of
bargainChefFlow, including requests from your Astro app. Open one and you’ll see thegetIngredientsOnSaletool call with thedayTypethe model chose, the model invocation, and each streamed chunk that the browser received. - The Flows tab lets you run
bargainChefFlowdirectly with custom input, which is useful for iterating on the prompt without round-tripping through the UI.
What you built
Section titled “What you built”You now have a working Genkit app that streams structured output from Gemini into an Astro UI incrementally, calls a tool during generation to ground the model’s response in mock sale-price data, validates input and output against schemas, and surfaces every step in a local trace UI.
Next steps
Section titled “Next steps”- Creating flows: Compose multi-step flows, branch on input, and chain model calls.
- Generating content: Swap Gemini for another provider, tune sampling parameters, and work with multimodal input.
- Deploy your app: Ship to Cloud Run, Vercel, Firebase, or your own infrastructure.
- Developer tools: Dig deeper into the Developer UI, tracing, and evaluation.