Skip to content

Astro tutorial

In this tutorial, you’ll build Bargain Chef, a full-stack Astro app where one Astro project serves both your Genkit backend and the UI. It uses two AI patterns Genkit simplifies: streaming structured output and tool calling.

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.

Keeping the backend and UI in one project gives you shared TypeScript types, no CORS configuration during local development, and a single deployment path.

  • Node.js v20 or later
  • npm
  • Familiarity with Astro and TypeScript
Terminal window
npm create astro@latest my-genkit-astro
cd my-genkit-astro

When prompted, choose the Empty template and enable TypeScript.

Terminal window
npm install genkit @genkit-ai/google-genai @genkit-ai/fetch
npm install -D genkit-cli tsx

These packages include:

  • genkit: Core Genkit SDK.
  • @genkit-ai/google-genai: Plugin that connects Genkit to Google’s Gemini models.
  • @genkit-ai/fetch: Exposes Genkit flows over the standard Web Fetch API, which Astro endpoints use.
  • genkit-cli: Genkit CLI tool that enables local testing and observability.

This tutorial uses the Gemini API from Google AI Studio.

Get a Gemini API Key

Set the GEMINI_API_KEY environment variable to your key:

Terminal window
export GEMINI_API_KEY=<your API key>

Astro endpoints need a server runtime. Add the Node.js adapter:

Terminal window
npx astro add node

Accept the prompts to install @astrojs/node and update astro.config.mjs automatically. The result looks like this:

astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
});

The backend prompts Gemini to draft a recipe, lets the model call a tool to look up mock grocery sale prices, and streams the partial recipe back to the browser as it’s generated.

The core AI logic lives in a flow, which is a Genkit-managed function that adds observability, type safety, and tooling integration on top of a regular async function.

You’ll build the backend in four parts:

  1. Initialize Genkit and register Gemini as the model provider.
  2. Define a tool the model can call to fetch sale prices.
  3. Describe the recipe shape with Zod so Genkit can validate the final output and stream partial recipe chunks.
  4. Define the flow that ties everything together.

Create src/genkit/bargainChefFlow.ts:

src/genkit/bargainChefFlow.ts
import { googleAI } from '@genkit-ai/google-genai';
import { genkit, z } from 'genkit';
const ai = genkit({
plugins: [googleAI()],
});
const getIngredientsOnSale = ai.defineTool(
{
name: 'getIngredientsOnSale',
description:
'Returns the ingredients on sale at the local grocery store, with prices. The sale set differs between weekdays and weekends.',
inputSchema: z.object({
dayType: z
.enum(['weekday', 'weekend'])
.describe('Whether to fetch weekday or weekend sale prices.'),
}),
outputSchema: z.array(
z.object({
name: z.string(),
price: z.string(),
}),
),
},
async ({ dayType }) => {
// Mock data: in a real app, query a pricing database.
return dayType === 'weekend'
? [
{ name: 'chicken breast', price: '$2.99/lb' },
{ name: 'pasta', price: '$0.79' },
{ name: 'canned tomatoes', price: '$0.99' },
{ name: 'garlic', price: '$0.50 / head' },
{ name: 'olive oil', price: '$6.99' },
]
: [
{ name: 'eggs', price: '$3.49 / dozen' },
{ name: 'spinach', price: '$1.99' },
{ name: 'parmesan', price: '$4.99' },
{ name: 'lemons', price: '$0.50 each' },
{ name: 'rice', price: '$2.49' },
{ name: 'butter', price: '$3.99' },
];
},
);
const BargainChefInputSchema = z.object({
craving: z.string().describe('What the user feels like eating right now.'),
});
const RecipeSchema = z.object({
title: z.string(),
description: z.string(),
servings: z.number(),
ingredients: z.array(
z.object({
name: z.string(),
quantity: z.string(),
onSale: z.boolean(),
}),
),
steps: z.array(z.string()),
});
// Exported for the frontend to import as types.
export type BargainChefInput = z.infer<typeof BargainChefInputSchema>;
export type Recipe = z.infer<typeof RecipeSchema>;
export type PartialRecipe = Partial<Recipe>;
export const bargainChefFlow = ai.defineFlow(
{
name: 'bargainChefFlow',
inputSchema: BargainChefInputSchema,
outputSchema: RecipeSchema,
streamSchema: RecipeSchema.partial(),
},
async ({ craving }, { sendChunk }) => {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long' });
const { stream, response } = ai.generateStream({
model: googleAI.model('gemini-flash-latest', {
temperature: 0.7,
thinkingConfig: { thinkingLevel: 'MINIMAL' },
}),
prompt: `Today is ${today}. The user is craving: ${craving}.
Call the getIngredientsOnSale tool with the dayType that matches today. Saturday and Sunday are weekends; all other days are weekdays. Then propose ONE recipe that takes advantage of those deals. For each ingredient, set onSale=true if it appears in the tool's response, false otherwise.`,
tools: [getIngredientsOnSale],
output: { schema: RecipeSchema },
});
for await (const chunk of stream) {
if (chunk.output) sendChunk(chunk.output);
}
const { output } = await response;
if (!output) throw new Error('Failed to generate recipe');
return output;
},
);

A few details are worth noting before you connect the UI:

  • Final output and streamed chunks: outputSchema is the complete recipe the flow returns at the end. streamSchema is the same shape with every field optional (RecipeSchema.partial()), because early chunks might only include the title or description.
  • Shared TypeScript types: Recipe, PartialRecipe, and BargainChefInput are inferred from the Zod schemas with z.infer<...> and re-exported for the Astro page script to import. Because the page script imports them with import type, Genkit and the model plugin stay out of the browser bundle.
  • The getIngredientsOnSale tool: The model decides when to call it based on the prompt, and the typed inputSchema forces the model to pass dayType: 'weekday' or 'weekend'. In a real app, the tool would query a pricing database, inventory system, or third-party API.
  • sendChunk: Each call pushes the latest partial recipe to the browser, giving the UI a typed view of the generated JSON as it grows.

Expose the flow as an HTTP endpoint by creating src/pages/api/bargainChefFlow.ts:

src/pages/api/bargainChefFlow.ts
import type { APIRoute } from 'astro';
import { fetchHandler } from '@genkit-ai/fetch';
import { bargainChefFlow } from '../../genkit/bargainChefFlow';
export const prerender = false;
const handler = fetchHandler(bargainChefFlow);
export const POST: APIRoute = ({ request }) => handler(request);

fetchHandler wraps your flow in Genkit’s HTTP protocol, which supports streaming chunks, structured errors, and compatibility with the Genkit client SDK. Setting prerender = false keeps Astro from trying to statically pre-render the route at build time.

Verify that your project layout matches the structure below:

  • package.json
  • astro.config.mjs
  • Directorysrc
    • Directorygenkit
      • bargainChefFlow.ts
    • Directorypages
      • Directoryapi
        • bargainChefFlow.ts
      • index.astro

If you’re coding with an AI assistant, install the Genkit Agent Skills so it has structured guidance on Genkit APIs, patterns, and common errors:

Terminal window
npx skills add genkit-ai/skills

See Develop with AI for tool-specific installation instructions.

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.

You’ll drop the following script into src/pages/index.astro in the next step. It calls your API route and renders streamed updates. The script imports the flow’s TypeScript types with import type, so the UI and backend stay in sync without adding server code to the browser bundle:

src/pages/index.astro (script block)
import { streamFlow } from 'genkit/beta/client';
import type { PartialRecipe, Recipe } from '../genkit/bargainChefFlow';
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: PartialRecipe | 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<Recipe, PartialRecipe>({
url: '/api/bargainChefFlow',
input: { craving },
});
for await (const partial of result.stream) {
render(partial);
}
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.

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):

src/pages/index.astro
---
// 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.

Create public/styles.css with the following:

public/styles.css
: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%;
}
}

Start the Astro development server:

Terminal window
npm run dev

Open 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.

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.

Start the Developer UI from your project root:

Terminal window
npx genkit start -- tsx --watch src/genkit/bargainChefFlow.ts

This launches the Developer UI at http://localhost:4000 by default.

In the Developer UI:

  • The Traces tab shows every invocation of bargainChefFlow, including the ones triggered by your Astro app. Open one and you’ll see the getIngredientsOnSale tool call with the dayType the model chose, the model invocation, and each streamed chunk that the browser received.

  • The Flows tab lets you run bargainChefFlow directly with custom input, which is useful for iterating on the prompt without round-tripping through the UI. Try sample input like:

    { "craving": "something warm with chicken" }

You can also test the API route directly. Use the -N flag and an Accept: text/event-stream header to consume the streamed response:

Terminal window
curl -N -X POST http://localhost:4321/api/bargainChefFlow \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"data":{"craving":"something warm with chicken"}}'

The { "data": ... } wrapper is required: Genkit’s HTTP handler reads the flow input from the request body’s data field.

The response arrives as a series of SSE data: events, each containing the partial recipe accumulated so far.

This tutorial uses an Astro server endpoint for the shortest first-run path. To use the same UI against a standalone backend instead, change four things:

  1. Skip the Node.js adapter. A standalone setup doesn’t need server rendering, so you can leave Astro on its default output and omit the “Enable server rendering” step and the ## Create the backend section.

  2. Install the Genkit web client:

    Terminal window
    npm install genkit
  3. In the src/pages/index.astro script, define local TypeScript interfaces matching your flow’s streamed output instead of importing shared types.

  4. Point the streamFlow URL at your backend route:

    const result = streamFlow({
    url: 'http://localhost:8080/bargainChefFlow',
    input: { craving },
    });

Then enable CORS on your backend so it accepts requests from http://localhost:4321:

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.

  • 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.