Back to All Articles
flutterfirebaseaiarchitecturetypescript

Building Real-Time AI Experiences with Flutter, Firebase, and Dynamic UI Generation

Learn how to architect an AI-driven multi-screen Flutter application with Firebase backends, dynamic UI generation, and robust security rules.

2026-10-14
Building Real-Time AI Experiences with Flutter, Firebase, and Dynamic UI Generation

Let's look at how to architect and build a production-grade, multi-screen Flutter application that integrates generative AI models and dynamic user interfaces at runtime.

What you'll learn

  • Structuring a Flutter and Firebase monorepo for atomic deployments
  • Implementing declarative routing guards with GoRouter and Firebase custom claims
  • Orchestrating multi-step AI image generation and moderation workflows
  • Rendering dynamic UI components (GenUI) based on runtime model payloads

Monorepo Architecture and Project Layout

When building an app with multiple distinct client surfaces—like a kiosk, a kitchen display, and a public queue screen—separation of concerns starts at the repository level. We use a monorepo setup to share core business logic and types between our Flutter frontend and Firebase backend functions.

TSX
// firebase.json
{
  "hosting": {
    "public": "build/web",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
  "firestore": {
    "rules": "firestore.rules"
  }
}

Speed Note: Keeping your shared TypeScript types or Dart models in a dedicated package eliminates drift between what your Cloud Functions expect and what your UI payloads submit.

Securing Multi-Screen Web Apps with Declarative Routing

Deploying a Flutter app to the web means users can manipulate the browser address bar directly. If you have administrative or back-of-house screens (like barista dashboards or content moderators), you must enforce strict routing guards at the router level.

TSX
// router.dart
import 'package:go_router/go_router.dart';
import 'package:firebase_auth/firebase_auth.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/',
  redirect: (context, state) async {
    final user = FirebaseAuth.instance.currentUser;
    final loggedIn = user != null;
    final isModeratorRoute = state.matchedLocation.startsWith('/moderator');

    if (isModeratorRoute) {
      if (!loggedIn) return '/login';
      
      // Check Firebase custom claims for elevated permissions
      final tokenResult = await user.getIdTokenResult();
      final claims = tokenResult.claims ?? {};
      final isMod = claims['moderator'] == true;

      if (!isMod) return '/unauthorized';
    }

    return null;
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const KioskHomeScreen(),
    ),
    GoRoute(
      path: '/moderator',
      builder: (context, state) => const ModeratorDashboardScreen(),
    ),
  ],
);

Gotcha: Always verify custom claims on the server side inside your Firestore security rules and Cloud Functions. Client-side routing redirects are for UX only; they do not replace backend security.

Locking Down Firestore Security Rules

Never rely solely on client-side validation when accepting user inputs or handling write operations. Tie every write to authenticated user credentials with strict field-level validation rules in Firestore.

TSX
// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /orders/{orderId} {
      // Allow public creation of orders with strict schema validation
      allow create: if request.auth != null 
        && request.resource.data.keys().hasAll(['prompt', 'status', 'createdAt'])
        && request.resource.data.prompt is string
        && request.resource.data.prompt.size() <= 50
        && request.resource.data.status == 'pending';

      // Only allow moderators or assigned baristas to update order status
      allow update: if request.auth != null 
        && (request.auth.token.moderator == true || request.auth.token.barista == true);
        
      allow read: if true;
    }
  }
}

Orchestrating Multi-Tier Content Moderation

When accepting unstructured user text (like a "happy place" prompt), you need a multi-layered safety check before passing data to expensive generative image pipelines.

TSX
// moderation.ts
import { onRequest } from 'firebase-functions/v2/https';
import { GoogleGenAI } from '@google/genai';

const ai = new GoogleGenAI();

export const moderatePrompt = onRequest(async (req, res) => {
  const { prompt } = req.body;

  if (!prompt || typeof prompt !== 'string' || prompt.length > 50) {
    res.status(400).send({ error: 'Invalid prompt length or type.' });
    return;
  }

  try {
    const response = await ai.models.generateContent({
      model: 'gemini-2.5-flash',
      contents: [
        {
          role: 'user',
          parts: [
            { text: `Review the following user prompt for NSFW or inappropriate content. Output strictly JSON with keys "safe" (boolean) and "reason" (string).\n\nPrompt: "${prompt}"` }
          ]
        }
      ]
    });

    const resultText = response.text ?? '{}';
    const moderationResult = JSON.parse(resultText);

    res.status(200).json(moderationResult);
  } catch (err) {
    res.status(500).send({ error: 'Moderation service failed.' });
  }
});

Accessibility & Safety Tip: Combine automated LLM checks with internal image generation model filters (like safety classifiers in image APIs) and a final human-in-the-loop verification screen for public-facing deployments.

Rendering Dynamic UIs from Model Payloads

To build truly dynamic experiences, let your backend model return structural UI configuration payloads—often called GenUI. Depending on the question type returned by the model, your Flutter app can dynamically select the right widget from a restricted design-system catalog.

TSX
// dynamic_renderer.dart
import 'package:flutter/material.dart';

class DynamicQuestionRenderer extends StatelessWidget {
  final Map<String, dynamic> schema;
  final ValueChanged<String> onAnswerChanged;

  const DynamicQuestionRenderer({
    super.key,
    required this.schema,
    required this.onAnswerChanged,
  });

  @override
  Widget build(BuildContext context) {
    final String type = schema['type'] ?? 'text';
    final String label = schema['label'] ?? '';

    switch (type) {
      case 'text':
        return TextField(
          decoration: InputDecoration(labelText: label),
          onChanged: onAnswerChanged,
        );
      case 'choices':
        final List<String> options = List<String>.from(schema['options'] ?? []);
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(label, style: Theme.of(context).textTheme.titleMedium),
            ...options.map((option) => RadioListTile<String>(
                  title: Text(option),
                  value: option,
                  groupValue: null,
                  onChanged: (val) => val != null ? onAnswerChanged(val) : null,
                )),
          ],
        );
      default:
        return const Text('Unsupported UI component requested.');
    }
  }
}

Speed Note: Keep your runtime widget catalog small and constrained to predefined design system tokens. Giving LLMs total freedom over raw widget trees usually leads to layout breaks and brand inconsistency.


Summary and Next Steps

By combining Flutter's flexible multi-platform UI engine with Firebase's reactive backend services and generative AI models, you can ship complex real-time applications that adapt dynamically at runtime.

  • Next Steps:
    • Set up a monorepo structure separating your Flutter client code from Firebase functions and shared types.
    • Implement strict server-side validation rules and declarative routing guards to protect administrative dashboards.
    • Explore constrained GenUI patterns to let AI models render appropriate input widgets dynamically.