Next.js tutorial
In this tutorial, you’ll build Bargain Chef, a full-stack Next.js app where one Next.js project serves both your Genkit backend and the UI. 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.
Keeping the backend and UI in one project gives you shared TypeScript types, no CORS configuration during local development, and a single deployment path.
Prerequisites
Section titled “Prerequisites”- Node.js v20 or later
- npm
- Familiarity with Next.js and TypeScript
Set up the application
Section titled “Set up the application”Next.js supports two routing systems, and Genkit works with both. Choose the tab that matches your app: App Router (the default for new projects) or Pages Router (common in existing apps). The tabs stay in sync across this tutorial.
Create the Next.js project
Section titled “Create the Next.js project”npx create-next-app@latest --app --src-dir my-genkit-nextjscd my-genkit-nextjsInstall packages
Section titled “Install packages”npm install genkit @genkit-ai/google-genai @genkit-ai/nextnpm install -D genkit-cli tsxpnpm add genkit @genkit-ai/google-genai @genkit-ai/nextpnpm add -D genkit-cli tsxyarn add genkit @genkit-ai/google-genai @genkit-ai/nextyarn add -D genkit-cli tsxbun add genkit @genkit-ai/google-genai @genkit-ai/nextbun add -d genkit-cli tsxThese packages include:
genkit: Core Genkit SDK.@genkit-ai/google-genai: Plugin that connects Genkit to Google’s Gemini models.@genkit-ai/next: Route handler and client helpers for the Next.js App Router.genkit-cli: Genkit CLI tool that enables local testing and observability.
Configure a model API key
Section titled “Configure a model API key”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:
export GEMINI_API_KEY=<your API key>Create the Next.js project
Section titled “Create the Next.js project”npx create-next-app@latest --no-app --src-dir my-genkit-nextjscd my-genkit-nextjsThe --no-app flag scaffolds a Pages Router project (with pages/ and pages/api/).
Install packages
Section titled “Install packages”npm install genkit @genkit-ai/google-genai @genkit-ai/fetchnpm install -D genkit-cli tsxpnpm add genkit @genkit-ai/google-genai @genkit-ai/fetchpnpm add -D genkit-cli tsxyarn add genkit @genkit-ai/google-genai @genkit-ai/fetchyarn add -D genkit-cli tsxbun add genkit @genkit-ai/google-genai @genkit-ai/fetchbun add -d genkit-cli tsxThese packages include:
genkit: Core Genkit SDK.@genkit-ai/google-genai: Plugin that connects Genkit to Google’s Gemini models.@genkit-ai/fetch: Web-standard fetch handler. Pages Router routes adapt theirreq/resto a fetchRequest/Responseand call the fetch handler.genkit-cli: Genkit CLI tool that enables local testing and observability.
Configure a model API key
Section titled “Configure a model API key”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:
export GEMINI_API_KEY=<your API key>Create the backend
Section titled “Create the backend”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:
- Initialize Genkit and register Gemini as the model provider.
- Define a tool the model can call to fetch sale prices.
- Describe the recipe shape with Zod so Genkit can validate the final output and stream partial recipe chunks.
- Define the flow that ties everything together.
Create 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:
outputSchemais the complete recipe the flow returns at the end.streamSchemais the same shape with every field optional (RecipeSchema.partial()), because early chunks might only include the title or description. - Shared TypeScript types:
Recipe,PartialRecipe, andBargainChefInputare inferred from the Zod schemas withz.infer<...>and re-exported for the Next.js page to import. Because the page imports them withimport type, Genkit and the model plugin stay out of the browser bundle. - The
getIngredientsOnSaletool: The model decides when to call it based on the prompt, and the typedinputSchemaforces the model to passdayType: '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.
Add the route handler
Section titled “Add the route handler”Wire up the Genkit flow as a Next.js route handler. Create src/app/api/bargainChefFlow/route.ts:
import { bargainChefFlow } from '@/genkit/bargainChefFlow';import { appRoute } from '@genkit-ai/next';
export const POST = appRoute(bargainChefFlow);appRoute adapts the flow to Next.js’s Web Request/Response API and handles both streaming and non-streaming requests.
Check the project layout
Section titled “Check the project layout”Verify that your project layout matches the structure below:
- package.json
- … other Next.js config files
Directorysrc
Directoryapp
Directoryapi
DirectorybargainChefFlow
- route.ts
- layout.tsx
- page.tsx
- globals.css
Directorygenkit
- bargainChefFlow.ts
Add the API route
Section titled “Add the API route”Wire up the flow as a Pages Router API route. Create src/pages/api/bargainChefFlow.ts:
import type { NextApiRequest, NextApiResponse } from 'next';import { Readable } from 'node:stream';import { fetchHandlers } from '@genkit-ai/fetch';import { bargainChefFlow } from '../../genkit/bargainChefFlow';
export const config = { api: { bodyParser: true, responseLimit: false, },};
const handleFlow = fetchHandlers([bargainChefFlow], '/api');
export default async function handler( req: NextApiRequest, res: NextApiResponse,) { const host = req.headers.host || 'localhost:3000'; const url = new URL(req.url || '/', `http://${host}`); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value === undefined) continue; if (Array.isArray(value)) value.forEach((v) => headers.append(key, v)); else headers.set(key, String(value)); }
const webRequest = new Request(url, { method: req.method, headers, body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body ?? {}) : undefined, });
const webResponse = await handleFlow(webRequest); res.status(webResponse.status); webResponse.headers.forEach((value, key) => res.setHeader(key, value)); if (webResponse.body) { Readable.fromWeb(webResponse.body as any).pipe(res); } else { res.end(); }}The handler adapts each Pages Router req/res to a fetch Request/Response and forwards it to fetchHandlers, which dispatches to the right flow based on the URL path. Setting responseLimit: false lets streaming responses run longer than the default 4MB cap.
Check the project layout
Section titled “Check the project layout”Verify that your project layout matches the structure below:
- package.json
- … other Next.js config files
Directorysrc
Directorygenkit
- bargainChefFlow.ts
Directorypages
Directoryapi
- bargainChefFlow.ts
- index.tsx
- _app.tsx
- … other Next.js page files
Directorystyles
- Home.module.css
- … other style files
Optional: install the Genkit agent skills
Section titled “Optional: install the Genkit agent skills”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:
npx skills add genkit-ai/skillsSee Develop with AI for tool-specific installation instructions.
Build the Next.js UI
Section titled “Build the Next.js UI”The Next.js side calls the flow with streamFlow, then stores each partial recipe in React state so the component re-renders as fields arrive.
Update the page component
Section titled “Update the page component”Replace the contents of src/app/page.tsx with the following. The component 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:
'use client';
import { useState } from 'react';import { streamFlow } from '@genkit-ai/next/client';import type { bargainChefFlow } from '@/genkit/bargainChefFlow';import type { BargainChefInput, PartialRecipe } from '@/genkit/bargainChefFlow';
export default function Home() { const [craving, setCraving] = useState('something warm with chicken'); const [recipe, setRecipe] = useState<PartialRecipe | null>(null); const [isStreaming, setIsStreaming] = useState(false);
async function generateRecipe(event: React.FormEvent) { event.preventDefault(); if (!craving.trim()) return; setRecipe(null); setIsStreaming(true); try { const input: BargainChefInput = { craving }; // Pass the flow's type so input, output, and stream chunks are typed. const result = streamFlow<typeof bargainChefFlow>({ url: '/api/bargainChefFlow', input, }); for await (const partial of result.stream) { setRecipe(partial); } 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> );}The 'use client' directive at the top marks this as a client component, which is required because streamFlow runs in the browser and the component uses React hooks like useState.
Replace the contents of src/pages/index.tsx with the following. It imports streamFlow from the generic genkit/beta/client and 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:
import { useState } from 'react';import { streamFlow } from 'genkit/beta/client';import type { BargainChefInput, PartialRecipe, Recipe,} from '../genkit/bargainChefFlow';
export default function Home() { const [craving, setCraving] = useState('something warm with chicken'); const [recipe, setRecipe] = useState<PartialRecipe | null>(null); const [isStreaming, setIsStreaming] = useState(false);
async function generateRecipe(event: React.FormEvent) { event.preventDefault(); if (!craving.trim()) return; setRecipe(null); setIsStreaming(true); try { const input: BargainChefInput = { craving }; // streamFlow's generics are <FinalOutput, StreamChunk>. const result = streamFlow<Recipe, PartialRecipe>({ url: '/api/bargainChefFlow', input, }); for await (const partial of result.stream) { setRecipe(partial); } 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> );}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. 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 component stores each partial recipe in React state, so the page re-renders on every update.
Each recipe section is wrapped in a conditional so it only renders 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, and the onSubmit handler calls preventDefault() so the browser doesn’t reload the page before starting the streaming request.
Add styles
Section titled “Add styles”Replace the contents of src/app/globals.css with the following:
Create src/styles/globals.css (or replace the existing file), make sure it’s imported from src/pages/_app.tsx, and add the following:
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; background: #fafafa; min-height: 100vh; margin: 0; 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 the Next.js development server:
npm run devpnpm run devyarn devbun run devOpen http://localhost:3000, 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.
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.
Start the Developer UI from your project root:
npx genkit start -- tsx --watch src/genkit/bargainChefFlow.tspnpm dlx genkit start -- tsx --watch src/genkit/bargainChefFlow.tsyarn dlx genkit start -- tsx --watch src/genkit/bargainChefFlow.tsbunx genkit start -- tsx --watch src/genkit/bargainChefFlow.tsThis 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 Next.js 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. Try sample input like:{ "craving": "something warm with chicken" }
Or call the flow with curl
Section titled “Or call the flow with curl”You can also test the route directly. Use the -N flag and an Accept: text/event-stream header to consume the streamed response:
curl -N -X POST http://localhost:3000/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.
Use a standalone backend instead
Section titled “Use a standalone backend instead”This tutorial keeps the backend and UI in one Next.js project for the shortest first-run path. To use the same UI against a standalone backend instead, change four things:
-
Create the Next.js project without the in-project backend, and install only the Genkit web client:
Terminal window npx create-next-app@latest --app --src-dir my-genkit-nextjscd my-genkit-nextjsTerminal window npm install genkitTerminal window pnpm add genkitTerminal window yarn add genkitTerminal window bun add genkit -
Skip the
## Create the backendsection. Your standalone backend already exposesbargainChefFlow. -
In
src/app/page.tsx, importstreamFlowfromgenkit/beta/clientinstead of@genkit-ai/next/client, and define local TypeScript interfaces matching your flow’s streamed output instead of importing shared types. -
Point the
streamFlowURL 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:3000:
What you built
Section titled “What you built”You now have a working Genkit app that streams structured output from Gemini into a Next.js 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.