Skip to content

Flutter tutorial

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

Flutter web apps 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.

  • Flutter SDK
  • Dart SDK 3.10.0 or later
  • Familiarity with Flutter and Dart

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

Terminal window
flutter create my_genkit_flutter
cd my_genkit_flutter

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

Terminal window
flutter pub add genkit

Verify that your project layout matches the structure below:

  • pubspec.yaml
  • Directoryweb
    • index.html
    • other Flutter web files
  • Directorylib
    • main.dart

Your backend should already expose bargainChefFlow. For reference, here’s the shape the Flutter app expects:

// Input
{'craving': 'something warm with chicken'}
// Streamed output (partial: fields fill in over time)
{
'title': String?,
'description': String?,
'servings': num?,
'ingredients': [
{'name': String?, 'quantity': String?, 'onSale': bool?}
],
'steps': [String]
}

If your backend exposes a flow with a different name or shape, adjust the URL and the Recipe model in the next section accordingly.

At this point, your Flutter app is ready to call the backend flow you already created.

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

Replace the contents of lib/main.dart with the following. The flowUrl constant points to your standalone backend, so adjust the port or route if your backend doesn’t run at http://localhost:8080/bargainChefFlow.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:genkit/client.dart';
// Point this at the URL where your bargainChefFlow is served
const flowUrl = 'http://localhost:8080/bargainChefFlow';
void main() {
runApp(const BargainChefApp());
}
class BargainChefApp extends StatelessWidget {
const BargainChefApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bargain Chef',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF1A1A1A),
scaffoldBackgroundColor: const Color(0xFFFAFAFA),
useMaterial3: true,
),
home: const BargainChefPage(),
);
}
}
class BargainChefPage extends StatefulWidget {
const BargainChefPage({super.key});
@override
State<BargainChefPage> createState() => _BargainChefPageState();
}
class _BargainChefPageState extends State<BargainChefPage> {
final _cravingController = TextEditingController(
text: 'something warm with chicken',
);
final RemoteAction<Map<String, dynamic>, Recipe, Recipe, void>
_bargainChefFlow = defineRemoteAction(
url: flowUrl,
fromResponse: Recipe.fromJson,
fromStreamChunk: Recipe.fromJson,
);
Recipe? _recipe;
bool _isStreaming = false;
@override
void dispose() {
_cravingController.dispose();
_bargainChefFlow.dispose();
super.dispose();
}
Future<void> _generateRecipe() async {
final craving = _cravingController.text.trim();
if (craving.isEmpty || _isStreaming) return;
setState(() {
_recipe = null;
_isStreaming = true;
});
try {
final stream = _bargainChefFlow.stream(input: {'craving': craving});
await for (final partial in stream) {
if (!mounted) return;
setState(() => _recipe = partial);
}
await stream.onResult;
} on GenkitException catch (err) {
_showError('Failed to generate recipe: ${err.message}');
} catch (err) {
_showError('Failed to generate recipe: $err');
} finally {
if (mounted) setState(() => _isStreaming = false);
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final recipe = _recipe;
final textTheme = Theme.of(context).textTheme;
final sectionStyle =
textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold);
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
'Bargain Chef',
style: textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
"Tell me what you feel like eating and I'll suggest a recipe "
'built around today\'s grocery deals.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: TextField(
controller: _cravingController,
enabled: !_isStreaming,
onSubmitted: (_) => _generateRecipe(),
decoration: const InputDecoration(
hintText: 'What are you in the mood for?',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _isStreaming ? null : _generateRecipe,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF1A1A1A),
foregroundColor: Colors.white,
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(_isStreaming ? 'Cooking...' : 'Suggest a recipe'),
),
],
),
if (recipe != null) ...[
const SizedBox(height: 24),
Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
side: const BorderSide(color: Color(0xFFE5E5E5)),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (recipe.title?.isNotEmpty ?? false)
Text(
recipe.title!,
style: textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
if (recipe.description?.isNotEmpty ?? false) ...[
const SizedBox(height: 8),
Text(recipe.description!),
],
if (recipe.servings != null) ...[
const SizedBox(height: 8),
Text('Serves: ${recipe.servings}'),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 20),
Text('Ingredients', style: sectionStyle),
const SizedBox(height: 8),
for (final ingredient in recipe.ingredients)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'• ${[ingredient.quantity, ingredient.name].whereType<String>().join(' ')}',
),
if (ingredient.onSale == true)
Chip(
label: const Text('on sale'),
labelStyle:
TextStyle(color: Colors.green.shade800),
backgroundColor: Colors.green.shade50,
side: BorderSide.none,
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
),
),
],
if (recipe.steps.isNotEmpty) ...[
const SizedBox(height: 20),
Text('Steps', style: sectionStyle),
const SizedBox(height: 8),
for (final (index, step) in recipe.steps.indexed)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('${index + 1}. $step'),
),
],
],
),
),
),
],
],
),
),
),
);
}
}
class Recipe {
const Recipe({
this.title,
this.description,
this.servings,
this.ingredients = const [],
this.steps = const [],
});
final String? title;
final String? description;
final num? servings;
final List<RecipeIngredient> ingredients;
final List<String> steps;
factory Recipe.fromJson(dynamic json) {
final map = json as Map<String, dynamic>;
return Recipe(
title: map['title'] as String?,
description: map['description'] as String?,
servings: map['servings'] as num?,
ingredients: (map['ingredients'] as List<dynamic>? ?? [])
.map(RecipeIngredient.fromJson)
.toList(),
steps:
(map['steps'] as List<dynamic>? ?? []).whereType<String>().toList(),
);
}
}
class RecipeIngredient {
const RecipeIngredient({this.name, this.quantity, this.onSale});
final String? name;
final String? quantity;
final bool? onSale;
factory RecipeIngredient.fromJson(dynamic json) {
final map = json as Map<String, dynamic>;
return RecipeIngredient(
name: map['name'] as String?,
quantity: map['quantity'] as String?,
onSale: map['onSale'] as bool?,
);
}
}

defineRemoteAction creates a typed client for the backend flow. The stream method returns an async iterable of partial recipe objects, and stream.onResult waits for the final validated recipe. The app stores each partial recipe in Flutter state with setState, so the UI re-renders on every update.

Each recipe section is wrapped in a null or empty check (for example, recipe.title?.isNotEmpty ?? false) 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. The TextField calls the same submit handler from onSubmitted, so the user can submit by pressing Enter in addition to tapping the button.

Start your Genkit backend in one terminal by following the run instructions in the backend tutorial you used. Then start the Flutter web app in another terminal:

Terminal window
flutter run -d chrome

Open the Flutter app in Chrome, 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 your backend uses a different route or port, edit the flowUrl constant at the top of lib/main.dart.

If the request fails, check the browser console first. The most common issue is a CORS error or a flowUrl 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 Flutter 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 Flutter 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.