Skip to content

NestJS tutorial

In this tutorial, you’ll build Bargain Chef, a standalone Genkit backend on NestJS that exposes a recipe-generating flow over HTTP. It uses two AI patterns Genkit simplifies: streaming structured output and tool calling.

For each request, your server prompts Gemini to draft a recipe, and the model calls a tool to look up mock grocery sale prices so it can prefer on-sale ingredients. The server streams the recipe back field-by-field as it’s generated, so clients see progress before the full recipe is ready.

You can find the finished code on GitHub.

  • Node.js v20 or later
  • npm
  • Familiarity with NestJS and TypeScript
Terminal window
npx @nestjs/cli new my-genkit-nestjs
cd my-genkit-nestjs

When prompted, choose your preferred package manager.

Install the packages you need:

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

These packages include:

  • genkit: Core Genkit SDK.
  • @genkit-ai/google-genai: Plugin that connects Genkit to Google’s Gemini models.
  • @genkit-ai/express: Express handler for exposing flows over HTTP.
  • genkit-cli: CLI tool that enables Genkit 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>

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.

The backend handles requests from clients. For each request, it prompts Gemini to draft a recipe, lets the model call a tool to look up today’s grocery sale prices, and streams the partial recipe back to the caller as it’s generated.

The whole pipeline is a single Genkit flow. A flow is a special Genkit function with built-in observability, type safety, and tooling integration.

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 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()),
});
export const bargainChefFlow = ai.defineFlow(
{
name: 'bargainChefFlow',
inputSchema: z.object({
craving: z
.string()
.describe('What the user feels like eating right now.'),
}),
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 expose the flow:

  • 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.
  • 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 forwards the latest partial recipe to the caller, so output fills in field by field. After the stream completes, the flow awaits response so the HTTP request still resolves with a validated recipe.

Create src/genkit/genkit.controller.ts to expose the flow over HTTP using Genkit’s Express handler:

src/genkit/genkit.controller.ts
import { Controller, Post, Req, Res, Next } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express';
import { expressHandler } from '@genkit-ai/express';
import { bargainChefFlow } from './bargainChefFlow';
@Controller()
export class GenkitController {
private readonly handleBargainChef = expressHandler(bargainChefFlow);
@Post('bargainChefFlow')
bargainChef(@Req() req: Request, @Res() res: Response, @Next() next: NextFunction) {
return this.handleBargainChef(req, res, next);
}
}

NestJS runs on Express under the hood, so the @Req(), @Res(), and @Next() objects are Express’s own request, response, and next function. That lets you pass them straight to Genkit’s expressHandler (an Express request handler), which reads the input, runs the flow, and streams the response back as chunks arrive. Injecting @Res() puts the controller in manual-response mode, so the handler owns sending the reply.

Add GenkitController to your AppModule:

src/app.module.ts
import { Module } from '@nestjs/common';
import { GenkitController } from './genkit/genkit.controller';
@Module({
controllers: [GenkitController],
})
export class AppModule {}

Enable CORS in src/main.ts so a browser frontend served from a different origin (a Vite or Next.js dev server, for example) can call this NestJS backend:

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

app.enableCors() with no options allows all origins, so any browser frontend can call this endpoint during development. Before deploying, restrict it to the origins you actually serve (for example, app.enableCors({ origin: 'https://your-app.com' })).

Verify that your project layout matches the structure below:

  • package.json
  • tsconfig.json
  • Directorysrc
    • app.module.ts
    • main.ts
    • Directorygenkit
      • bargainChefFlow.ts
      • genkit.controller.ts

Start the NestJS development server:

Terminal window
npm run start:dev

By default, NestJS listens on http://localhost:3000. The flow is mounted at /bargainChefFlow through the controller. In the next section, you’ll send a request and watch the recipe stream 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.

You can test the flow directly with curl, and you can use the Developer UI to inspect both manual runs and requests from the running NestJS server.

With the server running, use the -N flag and an Accept: text/event-stream header to consume the streamed response:

Terminal window
curl -N -X POST http://localhost:3000/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 data: events. Each event contains the partial recipe accumulated so far, with fields such as title, ingredients, and steps filling in as the model generates them. The final event contains the complete, validated recipe.

The Developer UI is Genkit’s local console for testing flows and inspecting execution traces. It runs alongside your backend code, gives you a visual runner for any flow in your project, and records every tool call and model invocation so you can iterate on prompts and debug tool behavior.

  1. Start the Developer UI from your project root:

    Terminal window
    npx genkit start -- nest start --watch

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

  2. Select bargainChefFlow from the list of flows.

  3. Enter sample input:

    { "craving": "something warm with chicken" }
  4. Click Run.

    You’ll see the generated recipe, with a trace that builds in real time so you can follow the flow’s progress through each tool call and model invocation.

You now have a standalone Genkit backend on NestJS that streams structured output from Gemini over HTTP, 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.