Skip to content

React (Vite) tutorial

In this tutorial, you’ll build the React 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.

React apps built with Vite run entirely in the browser, so the Genkit flow always runs on a separate backend server. You’ll connect the UI to a standalone backend that exposes bargainChefFlow.

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.

  • Node.js v20 or later
  • npm
  • Familiarity with React and TypeScript

You should have already completed a matching backend tutorial. This tutorial picks up where that leaves off and adds a React 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.

Scaffold a new React project with Vite:

Terminal window
npm create vite@latest my-genkit-react -- --template react-ts
cd my-genkit-react
npm install

Install the Genkit web client, which lets the browser call your standalone backend:

Terminal window
npm install genkit

Verify that your project layout matches the structure below:

  • package.json
  • vite.config.ts
  • other Vite config files
  • Directorysrc
    • App.tsx
    • App.css
    • main.tsx
    • other React source files

Your backend should already expose bargainChefFlow. For reference, here’s the shape the React 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 React app is ready to call the backend flow you already created.

Now update the React app so the browser can call your Genkit backend and render streamed output.

Replace the contents of src/App.tsx with the following. The FLOW_URL constant points to your backend, so adjust the port or route if your backend doesn’t run at http://localhost:8080/bargainChefFlow.

src/App.tsx
import { useState } from 'react';
import { streamFlow } from 'genkit/beta/client';
import './App.css';
interface RecipeIngredient {
name?: string;
quantity?: string;
onSale?: boolean;
}
interface Recipe {
title?: string;
description?: string;
servings?: number;
ingredients?: RecipeIngredient[];
steps?: string[];
}
// Point this at the URL where your bargainChefFlow is served
const FLOW_URL = 'http://localhost:8080/bargainChefFlow';
function App() {
const [craving, setCraving] = useState('something warm with chicken');
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
async function generateRecipe(e: React.FormEvent) {
e.preventDefault();
if (!craving.trim()) return;
setRecipe(null);
setIsStreaming(true);
try {
const result = streamFlow({
url: FLOW_URL,
input: { craving },
});
// result.stream is an async iterable of partial recipes.
// Each chunk is the accumulated output so far.
for await (const partial of result.stream) {
setRecipe(partial as Recipe);
}
// Wait for the final validated output and surface any errors.
await result.output;
} catch (err) {
console.error('Failed to generate recipe', err);
} finally {
setIsStreaming(false);
}
}
return (
<main>
<h1>Bargain Chef</h1>
<p className="tagline">
Tell me what you feel like eating and I'll suggest a recipe built around
today's grocery deals.
</p>
<form className="prompt" onSubmit={generateRecipe}>
<input
type="text"
value={craving}
onChange={(e) => setCraving(e.target.value)}
name="craving"
placeholder="What are you in the mood for?"
disabled={isStreaming}
/>
<button type="submit" disabled={isStreaming}>
{isStreaming ? 'Cooking…' : 'Suggest a recipe'}
</button>
</form>
{recipe && (
<article>
{recipe.title && <h2>{recipe.title}</h2>}
{recipe.description && (
<p className="description">{recipe.description}</p>
)}
{recipe.servings && (
<p className="serves">
<strong>Serves:</strong> {recipe.servings}
</p>
)}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<>
<h3>Ingredients</h3>
<ul className="ingredients">
{recipe.ingredients.map((ing, i) => (
<li key={i}>
{ing.quantity} {ing.name}
{ing.onSale && <span className="badge">on sale</span>}
</li>
))}
</ul>
</>
)}
{recipe.steps && recipe.steps.length > 0 && (
<>
<h3>Steps</h3>
<ol className="steps">
{recipe.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</>
)}
</article>
)}
</main>
);
}
export default App;

streamFlow returns an object with two useful properties: stream, an async iterable of partial recipe objects, and output, a promise that resolves with the final validated recipe. The component stores each partial recipe in React state with setRecipe, so the UI re-renders on every update.

Each recipe section is wrapped in a truthy check (for example, recipe.title && ...) so it only renders once 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, and the onSubmit handler calls preventDefault() so the browser doesn’t reload the page before starting the streaming request.

Replace the contents of src/App.css with the following:

src/App.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;
background: #fafafa;
}
#root {
max-width: 640px;
margin: 0 auto;
}
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 your Genkit backend in one terminal by following the run instructions in the backend tutorial you used. Then start the Vite dev server in another terminal:

Terminal window
npm run dev

Open http://localhost:5173, 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 backend route.

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

You now have a working Genkit app that streams structured output from Gemini into a React 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.