How to add feature flags and experiments to a Flutter app

Show a baseline report or a summary variant, then measure report saves with the same analytics identity. Allow about 25 minutes.

  • Flutter 3.32+
  • Dart 3.8+
  • iOS and Android
  • Experiment SDK 1.0
  • 25 min

Chris Tam · Last updated 22 October 2026

Flutter keeps a screen alive across many widget rebuilds. I use feature flags to choose the report layout when someone opens it, then keep that choice for the screen's lifetime.

In this tutorial, I extend Flutter product analytics, fetch a report flag with the current user, show a summary to the treatment group, and compare saves after exposure.

A report with control and treatment layouts. Opening it reads report-summary; the treatment adds a summary, and Save report records Report Saved. The sample holds the saved state on the current screen; connect that action to your application's persistence when using it in production.

Prerequisites

  • The app from Flutter product analytics, including its single AppAnalytics instance, named screen observer and verified-session adapter. Keep lib/analytics.dart and lib/auth.dart unchanged.
  • Flutter 3.32 or later and Dart 3.8 or later for amplitude_experiment 1.0.0. The analytics package remains pinned to 4.7.3. Configure an iOS or Android target and retain its platform files.
  • A client deployment key from the same Amplitude project as the analytics API key. Use a client deployment in this mobile app; server deployment keys stay on a server. Match the project's region in both SDKs.

Sign up for free if you need an Amplitude account. Use the experiment quick start for project setup and experiment configuration.

1. Configure the report experiment

In Amplitude, create a feature experiment with flag key report-summary, variants control and treatment, and the client deployment used by the app. Define the exposure step and use Report Saved as the primary conversion metric. Save the configuration and use your test users to check both variants before widening the audience.

Keep the same key in the app's experiment module. Flutter and the dashboard need to agree on this string:

lib/flags.dart, flag key inside ReportFlags

lib/flags.dart, flag key inside ReportFlags
static const flagKey = 'report-summary';

Use the experiment configuration workflow to choose targeting and traffic allocation. A rollout selects who receives a variant; experiment analysis compares the defined conversion after exposure.

2. Add one Experiment client

Install the client package in the existing app:

Terminal, project root

Terminal, project root
flutter pub add amplitude_experiment:1.0.0

Create the complete module below. The Amplitude integration uses the existing analytics instance for identity and exposure delivery. Its instance name and region match that client.

lib/flags.dart

lib/flags.dart
import 'package:amplitude_experiment/amplitude_experiment.dart' as experiment;
import 'package:amplitude_flutter/constants.dart' as analytics;

import 'analytics.dart';
import 'auth.dart';

class ReportFlags {
  ReportFlags._(this._client, this._analytics);
  final experiment.ExperimentClient _client;
  final AppAnalytics _analytics;
  bool _ready = false;
  static const flagKey = 'report-summary';

  static Future<ReportFlags> create({
    required AppAnalytics analyticsClient,
    required String deploymentKey,
  }) async {
    if (deploymentKey.trim().isEmpty) {
      throw ArgumentError('Set AMPLITUDE_DEPLOYMENT_KEY with --dart-define.');
    }
    final client = await experiment.Experiment.initializeWithAmplitude(
      deploymentKey,
      experiment.ExperimentConfig(
        instanceName: analyticsClient.client.configuration.instanceName,
        serverZone:
            analyticsClient.client.configuration.serverZone ==
                analytics.ServerZone.eu
            ? experiment.ServerZone.eu
            : experiment.ServerZone.us,
        fetchTimeoutMillis: 1500,
        retryFetchOnFailure: false,
        automaticFetchOnAmplitudeIdentityChange: false,
      ),
    );
    return ReportFlags._(client, analyticsClient);
  }

  // Await identity transitions before enabling report navigation again.
  Future<void> refresh(AppUser? user) async {
    _ready = false;
    try {
      await _client.clear();
      final deviceId = await _analytics.client.getDeviceId();
      if (deviceId == null || deviceId.length < 5) return;
      await _client.fetch(
        experiment.ExperimentUser(
          userId: user?.id,
          deviceId: deviceId,
          userProperties: user == null ? {} : {'plan': user.plan},
        ),
        const experiment.FetchOptions(flagKeys: [flagKey]),
      );
      _ready = true;
    } catch (_) {
      // Keep the baseline available without reading another user's cache.
    }
  }

  Future<String> layoutAtUse() async {
    if (!_ready) return 'control';
    try {
      final variant = await _client.variant(flagKey);
      return variant.value == 'treatment' ? 'treatment' : 'control';
    } catch (_) {
      return 'control';
    }
  }

  Future<void> clear() async {
    _ready = false;
    await _client.clear();
  }
}

refresh clears the prior cache, sends the current identity and plan, and waits for the fetch. Passing the plan explicitly avoids relying on asynchronous profile enrichment. The baseline remains available when no variant is assigned or a request fails. That baseline does not establish a successful control assignment.

Why. variant() can track exposure. I call it only when opening the report, after the fetch, and retain that result for the screen. Reading a flag from every widget build would tie measurement to rebuilds.

3. Synchronize authentication and flag refresh

Keep analytics authentication separate from Experiment. Call this coordinator after the application's existing login succeeds, on relevant plan changes, and after sign-out completes. Its queue keeps two identity changes from racing.

lib/flag_session.dart

lib/flag_session.dart
// Keep Replay pause/resume and engagement cleanup added by later Flutter pages.
import 'analytics.dart';
import 'auth.dart';
import 'flags.dart';

class FlagSession {
  FlagSession(this.analytics, this.flags);
  final AppAnalytics analytics;
  final ReportFlags flags;
  Future<void> _pending = Future<void>.value();

  Future<void> update(AppUser? user, {bool signingOut = false}) {
    final operation = _pending.then((_) async {
      // Run engagement closeAll and pause Replay here before identity changes.
      if (signingOut) {
        await analytics.signedOut();
      } else if (user != null) {
        await analytics.signedIn(user);
      }
      await flags.refresh(user);
      // Resume Replay only after it has the current analytics IDs.
    });
    _pending = operation.catchError((Object _) {});
    return operation;
  }
}

Connect a successful login by awaiting session.update(verifiedUser). On sign-out, await session.update(null, signingOut: true) before allowing the next person's report navigation. Keep the report UI disabled while either operation is pending. On anonymous startup, call update(null) without signingOut; resetting on each launch would discard the existing device identity.

The sign-out sequence resets analytics, then clears and refetches flags. If you also use guides and surveys, close them before analytics reset. Pause replay before the identity change and resume it only after synchronizing its IDs. Merge these additions into this coordinator; keep the existing hooks.

4. Render the variant and record saves

Add the conversion helper. The action records the same canonical user and device identity already set on the analytics client.

lib/report_events.dart

lib/report_events.dart
import 'package:amplitude_flutter/events/base_event.dart';
import 'analytics.dart';

Future<void> reportSaved(AppAnalytics analytics) async {
  try {
    await analytics.client.track(
      BaseEvent(
        'Report Saved',
        eventProperties: {
          'report_type': 'usage',
          'days': 7,
          'framework': 'flutter',
        },
      ),
    );
  } catch (_) {
    // Saving a report remains usable if analytics is unavailable.
  }
}

Merge the following root and report-screen changes with existing routes, observers and application imports. This complete sample retains Home and a named /report route. The native platform configuration and the two unchanged base modules remain in place.

lib/main.dart

lib/main.dart
// Keep analytics observers and Replay/engagement additions from other Flutter pages.
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/observers/amplitude_navigator_observer.dart';
import 'package:flutter/material.dart';

import 'analytics.dart';
import 'auth.dart';
import 'flag_session.dart';
import 'flags.dart';
import 'report_events.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const zone = String.fromEnvironment(
    'AMPLITUDE_SERVER_ZONE',
    defaultValue: 'US',
  );
  final analytics = await AppAnalytics.create(
    apiKey: const String.fromEnvironment('AMPLITUDE_API_KEY'),
    serverZone: zone == 'EU' ? ServerZone.eu : ServerZone.us,
  );
  final flags = await ReportFlags.create(
    analyticsClient: analytics,
    deploymentKey: const String.fromEnvironment('AMPLITUDE_DEPLOYMENT_KEY'),
  );
  final session = FlagSession(analytics, flags);
  await session.update(await restoreSignedInUser());
  runApp(FlagReportsApp(analytics: analytics, flags: flags));
}

class FlagReportsApp extends StatelessWidget {
  const FlagReportsApp({
    super.key,
    required this.analytics,
    required this.flags,
  });
  final AppAnalytics analytics;
  final ReportFlags flags;

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Usage reports',
    navigatorObservers: [AmplitudeNavigatorObserver(analytics.client)],
    routes: {'/': (context) => ReportHome(analytics: analytics, flags: flags)},
  );
}

class ReportHome extends StatefulWidget {
  const ReportHome({super.key, required this.analytics, required this.flags});
  final AppAnalytics analytics;
  final ReportFlags flags;
  @override
  State<ReportHome> createState() => _ReportHomeState();
}

class _ReportHomeState extends State<ReportHome> {
  bool _opening = false;
  Future<void> _open() async {
    if (_opening) return;
    setState(() => _opening = true);
    final layout = await widget.flags.layoutAtUse();
    if (!mounted) return;
    await Navigator.of(context).push(
      MaterialPageRoute<void>(
        settings: const RouteSettings(name: '/report'),
        builder: (_) =>
            FlagReportScreen(analytics: widget.analytics, layout: layout),
      ),
    );
    if (mounted) setState(() => _opening = false);
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Usage reports')),
    body: Center(
      child: FilledButton(
        onPressed: _opening ? null : _open,
        child: const Text('Preview report'),
      ),
    ),
  );
}

class FlagReportScreen extends StatefulWidget {
  const FlagReportScreen({
    super.key,
    required this.analytics,
    required this.layout,
  });
  final AppAnalytics analytics;
  final String layout;
  @override
  State<FlagReportScreen> createState() => _FlagReportScreenState();
}

class _FlagReportScreenState extends State<FlagReportScreen> {
  bool _saved = false;
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) widget.analytics.reportPreviewed();
    });
  }

  void _save() {
    if (_saved) return;
    setState(() => _saved = true);
    // This sample saves to the current screen's state; persist in your app here.
    reportSaved(widget.analytics);
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Seven-day usage')),
    body: Column(
      children: [
        const Text('Your usage report is ready.'),
        if (widget.layout == 'treatment')
          const Text('Your last seven days are ready to save.'),
        FilledButton(
          onPressed: _saved ? null : _save,
          child: Text(_saved ? 'Report saved' : 'Save report'),
        ),
      ],
    ),
  );
}

The report preview event stays on the destination screen. Saving changes the sample's screen state and queues one Report Saved event. It does not fetch the flag again, manually send an exposure, or duplicate the preview.

MCP. Use the Amplitude MCP server with the existing project to inspect event names before creating a second conversion name for an action your app already measures.

5. Run with matching project and deployment keys

Pass both values into the Flutter build. Set AMPLITUDE_SERVER_ZONE=EU through a third --dart-define when using an EU project.

Terminal, project root

Terminal, project root
flutter run --dart-define=AMPLITUDE_API_KEY=your-project-api-key --dart-define=AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key

Restart after changing build-time values. Keep server keys and application authentication credentials out of these defines.

Verify it worked

  1. Assign a test user to control, launch the app, and open the report. Confirm the summary is absent. Return Home and repeat with a user assigned to treatment; the summary should appear before the report is used.
  2. Rebuild the report widget without reopening it. Confirm the app does not read the variant or record another report preview solely because it rebuilt.
  3. In Users > User Profiles, inspect the same user or device. Confirm Report Previewed, the report screen event and Report Saved after tapping Save. Verify report_type: usage, days: 7, and framework: flutter on the custom events.
  4. Verify exposure delivery from the Analytics integration and the [Experiment] Exposure event used in experiment analysis. Confirm the metric uses Report Saved from that same project and identity.
  5. Change the signed-in plan, await the coordinated update and reopen the report. Check the new targeting context. Sign out, await reset/clear/refetch, and verify the next report uses the new anonymous device identity rather than the previous account's variant.
  6. Exercise a failed fetch and an unassigned user. Both should show the baseline; neither is evidence that the user was successfully assigned to the control group.

Troubleshooting

The deployment key is missing or belongs to another project

Pass a nonempty client deployment key with --dart-define. The module rejects an empty value. Check that the flag belongs to that deployment and that the deployment's project matches analytics.

EU users receive no variants

Use the EU project key and EU deployment key together. The module maps the analytics region to Experiment's separate ServerZone enum. Keep the analytics instance name identical to Experiment's.

Plan targeting uses an old value

Await session.update(verifiedUser) after changing the trusted plan, then reopen the report. refresh sends the new plan explicitly instead of waiting for a remote profile update.

A new account receives a previous account's layout

Block navigation through the whole identity transition. Reset analytics at sign-out, clear the variant cache and refetch before reading the report flag. Replacing only the visible profile name does not synchronize either SDK.

Conversions arrive without useful experiment results

Check the exposure step, experiment allocation and conversion identity. A visible baseline after an error is an availability fallback. Inspect processed exposures and the experiment analysis before interpreting a conversion difference.

FAQ

Does fetching a flag count as using its variant?

This example fetches before rendering and reads variant() when opening the report. The Analytics integration supplies exposure tracking for that read. Saving the report does not read it again.

Does the flag control report permissions?

Authentication and authorization remain in your application. This experiment changes a cosmetic summary. Keep access checks outside the flag decision.

Does Save report persist anything remotely?

The sample keeps a saved indicator on the current screen and sends the analytics event. Connect your actual save operation before that event in an existing app, so the conversion represents its successful outcome.

Full code reference

Unchanged from Flutter product analytics; keep your versions: lib/analytics.dart, lib/auth.dart, native platform files and app-specific authentication. Merge the root changes instead of deleting other routes or product integrations.

Full code reference
// -- lib/flags.dart --
import 'package:amplitude_experiment/amplitude_experiment.dart' as experiment;
import 'package:amplitude_flutter/constants.dart' as analytics;

import 'analytics.dart';
import 'auth.dart';

class ReportFlags {
  ReportFlags._(this._client, this._analytics);
  final experiment.ExperimentClient _client;
  final AppAnalytics _analytics;
  bool _ready = false;
  static const flagKey = 'report-summary';

  static Future<ReportFlags> create({
    required AppAnalytics analyticsClient,
    required String deploymentKey,
  }) async {
    if (deploymentKey.trim().isEmpty) {
      throw ArgumentError('Set AMPLITUDE_DEPLOYMENT_KEY with --dart-define.');
    }
    final client = await experiment.Experiment.initializeWithAmplitude(
      deploymentKey,
      experiment.ExperimentConfig(
        instanceName: analyticsClient.client.configuration.instanceName,
        serverZone:
            analyticsClient.client.configuration.serverZone ==
                analytics.ServerZone.eu
            ? experiment.ServerZone.eu
            : experiment.ServerZone.us,
        fetchTimeoutMillis: 1500,
        retryFetchOnFailure: false,
        automaticFetchOnAmplitudeIdentityChange: false,
      ),
    );
    return ReportFlags._(client, analyticsClient);
  }

  // Await identity transitions before enabling report navigation again.
  Future<void> refresh(AppUser? user) async {
    _ready = false;
    try {
      await _client.clear();
      final deviceId = await _analytics.client.getDeviceId();
      if (deviceId == null || deviceId.length < 5) return;
      await _client.fetch(
        experiment.ExperimentUser(
          userId: user?.id,
          deviceId: deviceId,
          userProperties: user == null ? {} : {'plan': user.plan},
        ),
        const experiment.FetchOptions(flagKeys: [flagKey]),
      );
      _ready = true;
    } catch (_) {
      // Keep the baseline available without reading another user's cache.
    }
  }

  Future<String> layoutAtUse() async {
    if (!_ready) return 'control';
    try {
      final variant = await _client.variant(flagKey);
      return variant.value == 'treatment' ? 'treatment' : 'control';
    } catch (_) {
      return 'control';
    }
  }

  Future<void> clear() async {
    _ready = false;
    await _client.clear();
  }
}

// -- lib/flag_session.dart --
// Keep Replay pause/resume and engagement cleanup added by later Flutter pages.
import 'analytics.dart';
import 'auth.dart';
import 'flags.dart';

class FlagSession {
  FlagSession(this.analytics, this.flags);
  final AppAnalytics analytics;
  final ReportFlags flags;
  Future<void> _pending = Future<void>.value();

  Future<void> update(AppUser? user, {bool signingOut = false}) {
    final operation = _pending.then((_) async {
      // Run engagement closeAll and pause Replay here before identity changes.
      if (signingOut) {
        await analytics.signedOut();
      } else if (user != null) {
        await analytics.signedIn(user);
      }
      await flags.refresh(user);
      // Resume Replay only after it has the current analytics IDs.
    });
    _pending = operation.catchError((Object _) {});
    return operation;
  }
}

// -- lib/report_events.dart --
import 'package:amplitude_flutter/events/base_event.dart';
import 'analytics.dart';

Future<void> reportSaved(AppAnalytics analytics) async {
  try {
    await analytics.client.track(
      BaseEvent(
        'Report Saved',
        eventProperties: {
          'report_type': 'usage',
          'days': 7,
          'framework': 'flutter',
        },
      ),
    );
  } catch (_) {
    // Saving a report remains usable if analytics is unavailable.
  }
}

// -- lib/main.dart --
// Keep analytics observers and Replay/engagement additions from other Flutter pages.
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/observers/amplitude_navigator_observer.dart';
import 'package:flutter/material.dart';

import 'analytics.dart';
import 'auth.dart';
import 'flag_session.dart';
import 'flags.dart';
import 'report_events.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const zone = String.fromEnvironment(
    'AMPLITUDE_SERVER_ZONE',
    defaultValue: 'US',
  );
  final analytics = await AppAnalytics.create(
    apiKey: const String.fromEnvironment('AMPLITUDE_API_KEY'),
    serverZone: zone == 'EU' ? ServerZone.eu : ServerZone.us,
  );
  final flags = await ReportFlags.create(
    analyticsClient: analytics,
    deploymentKey: const String.fromEnvironment('AMPLITUDE_DEPLOYMENT_KEY'),
  );
  final session = FlagSession(analytics, flags);
  await session.update(await restoreSignedInUser());
  runApp(FlagReportsApp(analytics: analytics, flags: flags));
}

class FlagReportsApp extends StatelessWidget {
  const FlagReportsApp({
    super.key,
    required this.analytics,
    required this.flags,
  });
  final AppAnalytics analytics;
  final ReportFlags flags;

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Usage reports',
    navigatorObservers: [AmplitudeNavigatorObserver(analytics.client)],
    routes: {'/': (context) => ReportHome(analytics: analytics, flags: flags)},
  );
}

class ReportHome extends StatefulWidget {
  const ReportHome({super.key, required this.analytics, required this.flags});
  final AppAnalytics analytics;
  final ReportFlags flags;
  @override
  State<ReportHome> createState() => _ReportHomeState();
}

class _ReportHomeState extends State<ReportHome> {
  bool _opening = false;
  Future<void> _open() async {
    if (_opening) return;
    setState(() => _opening = true);
    final layout = await widget.flags.layoutAtUse();
    if (!mounted) return;
    await Navigator.of(context).push(
      MaterialPageRoute<void>(
        settings: const RouteSettings(name: '/report'),
        builder: (_) =>
            FlagReportScreen(analytics: widget.analytics, layout: layout),
      ),
    );
    if (mounted) setState(() => _opening = false);
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Usage reports')),
    body: Center(
      child: FilledButton(
        onPressed: _opening ? null : _open,
        child: const Text('Preview report'),
      ),
    ),
  );
}

class FlagReportScreen extends StatefulWidget {
  const FlagReportScreen({
    super.key,
    required this.analytics,
    required this.layout,
  });
  final AppAnalytics analytics;
  final String layout;
  @override
  State<FlagReportScreen> createState() => _FlagReportScreenState();
}

class _FlagReportScreenState extends State<FlagReportScreen> {
  bool _saved = false;
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) widget.analytics.reportPreviewed();
    });
  }

  void _save() {
    if (_saved) return;
    setState(() => _saved = true);
    // This sample saves to the current screen's state; persist in your app here.
    reportSaved(widget.analytics);
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Seven-day usage')),
    body: Column(
      children: [
        const Text('Your usage report is ready.'),
        if (widget.layout == 'treatment')
          const Text('Your last seven days are ready to save.'),
        FilledButton(
          onPressed: _saved ? null : _save,
          child: Text(_saved ? 'Report saved' : 'Save report'),
        ),
      ],
    ),
  );
}

Hand it to your agent

Coding-agent prompt

Coding-agent prompt
Extend the Flutter report app using https://amplitude.com/build/flutter-feature-flags.
Use amplitude_experiment 1.0.0 on Flutter >=3.32 and Dart >=3.8, alongside the existing amplitude_flutter 4.7.3 analytics client. Keep analytics/auth and native setup from the product analytics page.
InitializeWithAmplitude once with a client deployment key for the same project, matching instanceName and region. Reject an empty deployment key. Use a bounded fetch and disable background retry and automatic identity-change fetch in this explicitly coordinated flow.
Create report-summary with control and treatment variants. Clear cached variants at identity transitions; fetch a fresh ExperimentUser with the current user ID, current device ID and explicit plan, restricted to the report flag. Await auth/refresh before enabling report navigation. Do not reset an anonymous device at each startup.
Read variant only at report opening, preserve the layout during rebuilds, and retain named routes and the screen observer. Treatment adds the summary. Preserve Report Previewed once per opening. Record Report Saved once after the sample save action; integrate real persistence before calling it in an existing app. Do not manually duplicate exposure or read variants on save.
At logout, close engagement and pause replay if installed; reset analytics, clear/refetch flags, then synchronize replay IDs before resuming. Merge shared files and keep every other Flutter hub addition.
Verify variants, plan targeting, failure baseline, identity transitions, one conversion per save, processed exposure/conversion identity and experiment configuration. Report each file changed and the tests actually run.

What this unlocks

Feature flags: choose the report summary for the intended audience.

Experimentation: compare Report Saved after exposure to each layout.

Product analytics: follow report previews and saves with the same identity.

Next steps

Compare the report layouts

Inspect exposure and save events, then use experiment results to decide which experience to keep.