Genkit for Flutter: Moving AI Logic to a Dart Backend

Take the client-side Genkit setup from Part 2 and move it to a production-ready Dart server — without touching the UI layer

6 min read

In Part 1 we built a conversational shopping assistant powered by Flutter, GenUI, and Firebase AI. In Part 2 we swapped Firebase AI for Genkit and added flows, middleware, custom tools, structured output, and context — all running client-side.

Client-side Genkit is fine for prototyping. But when all your AI logic runs in the app, the API key is embedded in the client — exposed in a web build, readable from DevTools. The natural fix is to move logic that doesn’t need the UI to a backend server, where the API key never leaves.

In this post we move the AI logic that has no UI dependency to a Dart backend server using genkit_shelf. The Gemini API key moves with it, off the client for good.

The best part: since both the server and the Flutter app are Dart, the code barely changes.

The full source is available on the genkit-refactor-part2 branch.

What We’re Building

The architecture shift looks like this:

Diagram comparing Part 2 client-side architecture (ContentGenerator holds all flows and API key, exposed in app) with Part 3 server-side architecture (UI-dependent flows stay in Flutter, data flows and API key move to a Dart server)

The UI layer stays completely untouched. The ContentGenerator interface is the same. We’re only changing where the data work happens.

1. Understanding the Split

Before writing any server code, it helps to be precise about what belongs on the server and what doesn’t.

The GenUI tools — surfaceUpdate, beginRendering, deleteSurface — emit events to streams that drive the Flutter widget tree. They need direct access to the UI. They stay in the Flutter app.

The shoppingAssistantFlow orchestrates those UI tools, so it stays in Flutter too.

But two flows have zero UI dependency:

  • searchProductsFlow — takes a query, searches an inventory, returns JSON
  • quickRecommendationFlow — takes a user query, calls Gemini with structured output, returns typed recommendations

Both are pure data operations. Both belong on the server.

WhatLives onWhy
surfaceUpdate, beginRendering, deleteSurfaceFlutter appNeeds UI access
shoppingAssistantFlowFlutter appOrchestrates UI tools
searchProductsFlowDart serverPure data, no UI dependency
quickRecommendationFlowDart serverPure data, no UI dependency
Gemini API keyDart serverNever exposed to client
ShoppingContextFlutter appSource of truth for cart state

The Flutter app remains the source of truth for cart state — it sends that state to the server with each request. The server uses it, derives what it needs, and discards it. This keeps the server stateless, which matters: a shared mutable context on the server means user A’s cart bleeds into user B’s request. Each request gets its own fresh ShoppingContext instance derived purely from the payload.

2. Setting Up the Dart Server

Create a backend/ folder at the root of the project with its own pubspec.yaml:

name: shopping_assistant_backend
description: Dart backend server for the GenUI Shopping Assistant.
publish_to: none
version: 0.0.1

environment:
  sdk: '^3.10.0'

dependencies:
  genkit: ^0.12.0
  genkit_google_genai: ^0.2.3
  genkit_shelf: ^0.1.0
  schemantic: ^0.4.0

dev_dependencies:
  lints: ^4.0.0

Add an analysis_options.yaml referencing package:lints/recommended.yaml so linting rules are enforced in the backend package just as they are in the Flutter app. Then run dart pub get inside backend/ and you’re ready.

Note: product_data.dart and shopping_context.dart are copied into backend/lib/ for simplicity in this demo. In a production app, extract shared models into a separate Dart package and reference it via path dependency from both the Flutter app and the backend. This avoids the risk of the two copies drifting out of sync.

3. Exposing Flows as HTTP Endpoints

Create backend/bin/server.dart. Flows are defined exactly as they were in the Flutter app — defineFlow works the same on the server. The only difference is that instead of calling them directly, you pass them to startFlowServer, which exposes each flow as a POST endpoint at /<flowName>.

void main() async {
  final apiKey = Platform.environment['GOOGLE_API_KEY'] ?? '';
  if (apiKey.isEmpty) { print('ERROR: GOOGLE_API_KEY is not set.'); exit(1); }

  final ai = Genkit(plugins: [googleAI(apiKey: apiKey), RetryPlugin()]);

  final searchProductsFlow = ai.defineFlow<Map<String, dynamic>, String, void, void>(
    name: 'searchProductsFlow',
    fn: (input, _) async { /* ... */ },
  );

  final quickRecommendationFlow = ai.defineFlow<String, List<Map<String, dynamic>>, void, void>(
    name: 'quickRecommendationFlow',
    fn: (userQuery, _) async { /* ... */ },
  );

  await startFlowServer(
    flows: [searchProductsFlow, quickRecommendationFlow],
    port: 3400,
    cors: {'origin': Platform.environment['ALLOWED_ORIGIN'] ?? '*'},
  );
}

The API key comes from an environment variable — never hardcoded. ALLOWED_ORIGIN defaults to * for local development and should be set to your app’s domain in production.

One production detail worth calling out: each request to searchProductsFlow gets a fresh ShoppingContext derived from the payload. The Flutter app sends its current cart state with every request, and the server uses it to annotate results.

fn: (input, _) async {
  // Fresh instance per request — a shared instance would bleed
  // cart state between users on the server.
  final shoppingContext = ShoppingContext();
  final cartItems = (input['cartItems'] as List<dynamic>?)
      ?.map((e) => e as String).toList() ?? [];
  for (final item in cartItems) {
    shoppingContext.addToCart(item, 0);
  }
  // ... search and return results
},

The flow definitions themselves — tools, outputSchema, retry middleware — are identical to Part 2. They moved files, not concepts. The full implementation is in backend/bin/server.dart.

Start the server:

GOOGLE_API_KEY=your_key dart run bin/server.dart

Before touching the Flutter app, verify both endpoints work independently:

# Test searchProducts
curl -X POST http://localhost:3400/searchProductsFlow \
  -H "Content-Type: application/json" \
  -d '{"data": {"query": "running shoes"}}'

# Test quickRecommendation
curl -X POST http://localhost:3400/quickRecommendationFlow \
  -H "Content-Type: application/json" \
  -d '{"data": "recommend me something for running"}'

Both should return JSON responses. The server is working.

4. Updating the Flutter App

Add http: ^1.2.0 to the Flutter app’s pubspec.yaml. Then open genkit_content_generator.dart and update the fn inside _searchProductsTool. This is the only meaningful change — instead of calling searchProducts() locally, it calls the server:

// Before (Part 2): local call
fn: (input, _) async {
  final results = searchProducts(query: input['query'] as String, ...);
  return jsonEncode(results.map((p) => p.toJson()).toList());
},

// After (Part 3): server call
fn: (input, _) async {
  final backendUrl = const String.fromEnvironment(
    'BACKEND_URL',
    defaultValue: 'http://localhost:3400',
  );
  final response = await http.post(
    Uri.parse('$backendUrl/searchProductsFlow'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({
      'data': {
        'query': input['query'],
        if (input['category'] != null) 'category': input['category'],
        'cartItems': _shoppingContext.cartItems.map((i) => i.productName).toList(),
      }
    }),
  );
  if (response.statusCode != 200) {
    throw Exception('Backend error: ${response.statusCode}');
  }
  return (jsonDecode(response.body) as Map<String, dynamic>)['result'] as String;
},

The BACKEND_URL comes from --dart-define so it works in both development and production without a code change. The same pattern applies to _handleRecommendationQuery, which now calls quickRecommendationFlow on the server instead of running locally.

Also remove the _quickRecommendationFlow field and product_data.dart import from the Flutter app — neither is needed anymore.

5. Running the Full Stack

Two terminals — one for the server, one for Flutter.

Terminal 1:

cd backend
GOOGLE_API_KEY=your_key dart run bin/server.dart

Terminal 2:

flutter run -d chrome \
  --dart-define=GOOGLE_API_KEY=your_key \
  --dart-define=BACKEND_URL=http://localhost:3400

In production, set BACKEND_URL to your deployed server URL and ALLOWED_ORIGIN to your app’s domain. Neither value is hardcoded anywhere in the source.

What We Gained

The change across the whole project is small — two methods updated in the Flutter app, a new backend/ folder, one new dependency. What it delivers:

Security. The Gemini API key lives only on the server. It never touches the Flutter app.

Stateless server flows. Each request to searchProductsFlow gets a fresh ShoppingContext derived from the payload. No state bleeds between users.

Configurable URLs. BACKEND_URL and ALLOWED_ORIGIN come from environment variables. The same binary works in development, staging, and production without a rebuild.

Clean separation. Flows with UI dependencies stay in Flutter. Flows with data dependencies move to the server. The split follows a clear rule rather than being arbitrary — and it’s a pattern that scales to any Flutter AI integration, not just this app.

The UI didn’t change. The user experience didn’t change. The ContentGenerator abstraction means the Flutter app never needed to know where the work happened — and now it doesn’t need to know the server exists either.

What’s Next

The remaining Genkit logic — shoppingAssistantFlow itself — still runs in the Flutter app because it orchestrates the GenUI UI tools, which need direct access to the widget tree. Moving that flow to the server requires a different approach — specifically an A2UI agent backend, where the server drives UI events over a streaming protocol. The genui_a2a package is designed exactly for this. That’s the natural next step for teams building production GenUI apps, and we’ll cover it in a future post.

The full source code for this post is on the genkit-refactor-part2 branch. Parts 1 and 2 cover the GenUI architecture and the client-side Genkit setup.

Frequently Asked Questions

Why move Genkit flows from the Flutter client to a Dart backend?

Client-side Genkit embeds the Gemini API key in the app, where a web build exposes it through DevTools. Moving data flows to a Dart backend keeps the key on the server and out of the client bundle.

Which flows belong on the server and which stay in the Flutter app?

Flows with no UI dependency move to the server. searchProductsFlow and quickRecommendationFlow are pure data operations, so they run on the backend. shoppingAssistantFlow stays in Flutter because it orchestrates the GenUI tools surfaceUpdate, beginRendering, and deleteSurface, which emit events that drive the widget tree.

How much does the Flutter app change when AI logic moves to the server?

Very little. The ContentGenerator interface stays identical, so the UI layer is untouched. Two tool functions swap their local call for an http.post against the backend, the _quickRecommendationFlow field comes out, and BACKEND_URL is read from a --dart-define.

Why does each request get a fresh ShoppingContext on the server?

A shared ShoppingContext on the server would bleed cart state between users. The Flutter app sends its current cart with every request, the server builds a new context from that payload, uses it to annotate results, and discards it. The server stays stateless and safe for concurrent users.

How do I configure the backend URL across environments?

BACKEND_URL is read with String.fromEnvironment, so it comes from --dart-define at build time and defaults to http://localhost:3400 locally. In production, pass your deployed server URL. Set ALLOWED_ORIGIN on the server to your app's domain so CORS only accepts requests from the real client.

Can the rest of the Genkit logic also move off the client?

Not with plain HTTP flows. shoppingAssistantFlow orchestrates GenUI tools that need direct access to the widget tree, so a request/response model does not fit. Moving it requires an A2UI agent backend that streams UI events over a protocol the client can render. The genui_a2a package is built for that.