Imagine you're building a B2B app. As your product grows and attracts more customers, you can boost revenue by offering a more brand-specific experience to each client. To be precise, you can build and sell a separate app for every customer, each with its own logo, colors, and even custom features, while still sharing the same core functionality.

How can this be achieved?

If your answer is duplicating the codebase and replacing logos, icons, styles, and configurations for each client, you've chosen the easiest — but also the most expensive — solution in the long run. As the product becomes more feature-rich, development and maintenance quickly become difficult. Every new feature, improvement, or bug fix must be implemented separately in each codebase, leading to duplicated effort, higher costs, and increased risk of inconsistencies.

This is where white-labelling comes into play.

White-labelling is an approach that allows you to deliver multiple Android and iOS apps from a single Flutter codebase, where each app has its own app name, bundle ID, icons, Firebase setup, and configuration — while all of them share the same underlying business logic.

In this article, we'll walk through a practical, production-ready approach to creating white-label Flutter apps for both Android and iOS. We'll cover how Android flavors and iOS targets and schemes work, how they map to Flutter's build system, and how to structure your project so adding a new client becomes a predictable and scalable process.

This guide is based on real-world experience, not just theory — and by the end, you'll have a clear, step-by-step roadmap that you can confidently use in production.

Let's get started 🔥

Note: Throughout this guide, we'll use two white-label variants of the same app as examples — one branded for "A" (default) and another for "B".

Android

Step 1: To configure white-label support on Android, we need to modify android/app/build.gradle.kts and define product flavors. A product flavor (often referred to simply as a flavor) is a feature of the Android Gradle Plugin that allows you to create and manage multiple variations of the same app from a single codebase.

Before:

...

defaultConfig {
        applicationId = "com.abdulabbozov.multi_scheme_demo"
        minSdk = flutter.minSdkVersion
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

...

After:

...

flavorDimensions += "app"

productFlavors {
    create("a") {
        dimension = "app"
        applicationId = "com.abdulabbozov.multi_scheme_demo.a"
        resValue("string", "app_name", "A")
    }

    create("b") {
        dimension = "app"
        applicationId = "com.abdulabbozov.multi_scheme_demo.b"
        resValue("string", "app_name", "B")
    }
}

defaultConfig {
    minSdk = flutter.minSdkVersion
    targetSdk = flutter.targetSdkVersion
    versionCode = flutter.versionCode
    versionName = flutter.versionName
}

...

Step 2: To assign a flavor-specific app label (name), we need to update the android/app/src/main/AndroidManifest.xml, allowing each white-label app to display its own name.

Before:

...

<application
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher"
    android:label="A">
...
</application>

...

After:

...

<application
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name">
...
</application>

...

Step 3: Now we'll configure flavor-specific app icons. The icon for the default flavor ("A") remains in android/app/src/main/res/, while for additional flavors (like "B"), we create a dedicated folder inside android/app/src/ and generate the corresponding app icons there.

dedicated folder for flavor “B” inside android/app/src/ that holds icon for this flavor

This is the final step of the white-label setup on the Android side. Before testing our configuration, we need to implement a simple brand-specific user interface on the Flutter side.

Flutter

Step 1: We need to implement simple brand-specific user interface in Flutter side for each of our flavors like below:

simple user interface for each flavor

To adapt the UI for each flavor, we define a new brand_config.dart file, which will hold flavor-specific configuration such as colors, logos, and styles:

import 'package:flutter/material.dart';

enum AppScheme { a, b }

/// Brand configuration class for white-labeling
/// This allows different branded versions of the app from a single codebase
class BrandConfig {
  final String brandName;
  final String appName;
  final String applicationId;
  final String bundleId;
  final BrandColors colors;
  final BrandAssets assets;

  const BrandConfig({
    required this.brandName,
    required this.appName,
    required this.applicationId,
    required this.bundleId,
    required this.colors,
    required this.assets,
  });
}

class BrandColors {
  final Color primaryColor;

  const BrandColors({required this.primaryColor});
}

class BrandAssets {
  final String logoPath;

  const BrandAssets({required this.logoPath});
}

class SchemeConfig {
  static AppScheme? _currentScheme;
  static BrandConfig? _brandConfig;

  static void setScheme(AppScheme scheme) {
    _currentScheme = scheme;
    _brandConfig = _getBrandConfig(scheme);
  }

  static AppScheme get currentScheme {
    return _currentScheme ?? AppScheme.a;
  }

  static BrandConfig get brandConfig {
    if (_brandConfig == null) {
      throw Exception(
        'Brand config not initialized! Call SchemeConfig.setScheme() first.',
      );
    }
    return _brandConfig!;
  }

  static bool get isA => _currentScheme == AppScheme.a;

  static bool get isB => _currentScheme == AppScheme.b;

  /// Get brand configuration for a specific scheme
  static BrandConfig _getBrandConfig(AppScheme scheme) {
    switch (scheme) {
      case AppScheme.a:
        return _a;
      case AppScheme.b:
        return _b;
    }
  }

  /// App A configuration
  static final BrandConfig _a = BrandConfig(
    brandName: 'A',
    appName: 'A',
    applicationId: 'com.abdulabbozov.multi_scheme_demo.a',
    bundleId: 'com.abdulabbozov.multiSchemeDemo.a',
    colors: BrandColors(primaryColor: Color(0xFFFF4444)),
    assets: BrandAssets(logoPath: 'assets/a.png'),
  );

  /// App B configuration
  static final BrandConfig _b = BrandConfig(
    brandName: 'B',
    appName: 'B',
    applicationId: 'com.abdulabbozov.multi_scheme_demo.b',
    bundleId: 'com.abdulabbozov.multiSchemeDemo.b',
    colors: BrandColors(primaryColor: Color(0xFF0974F4)),
    assets: BrandAssets(logoPath: 'assets/b.png'),
  );
}

To set flavor in Flutter side, we need to call setScheme function of SchemeConfig class inside main.dart:

...

void main({AppScheme scheme = AppScheme.a}) async {
  WidgetsFlutterBinding.ensureInitialized();
  SchemeConfig.setScheme(scheme);
  runApp(const MyApp());
}

...

Step 2: Build UI of main page based on flavor's BrandConfig

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final brandConfig = SchemeConfig.brandConfig;
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        backgroundColor: brandConfig.colors.primaryColor,
        appBar: AppBar(
          toolbarHeight: 0,
          centerTitle: true,
          backgroundColor: brandConfig.colors.primaryColor,
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 16),
              child: Image.asset(brandConfig.assets.logoPath),
            ),
            Text(
              "Brand Name: ${brandConfig.brandName}",
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.w600,
                color: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Step 3: Next we create two entry points: main_a.dart and main_b.dart that is used instead of default main.dart.

entry points for each flavor

main_a.dart:

import 'package:multi_scheme_demo/brand_config.dart';
import 'main.dart' as app;

void main() {
  app.main(scheme: AppScheme.a);
}

main_b.dart:

import 'package:multi_scheme_demo/brand_config.dart';
import 'main.dart' as app;

void main() {
  app.main(scheme: AppScheme.b);
}

Step 4: Now we edit default build configuration in AndroidStudio and create new ones per flavor.

editing build configuration in AndroidStudio

Before:

default build configuration of AndroidStudio

After:

build configuration for each flavor after overriding default of AndroidStudio

Step 5: With the configuration in place, it's time to test our setup.

testing final config and setup in android
test app label and icons of each flavor

iOS

Step 1: Now we setup white-label configuration on iOS. We start by duplicating existing Runner under Targets section of workspace in Xcode and rename it "B". In Xcode, "runner" often refers to the executable environment or a specific test runner utility used to execute the actions defined in the scheme, while the term "scheme" is a fundamental organizational tool that defines what to build and how to do it (run, test, profile, etc.)

duplicating guide of existing Runner target in Xcode

Step 2: Then we edit app identity of our new target.

after creating new runner, you need to fill identity information of new app

Step 3: It is optional but recommended to link a new ios/Runner/TARGET_NAME-Info.list (ios/Runner/b-Info.list in our case) by duplicating ios/Runner/Info.list and modifying necessary values.

Info.list (for default target (Runner) — "A"):

Info.plist for “A” target

b-Info.list (for target — "B"):

Info.plist for “B” target

To link the new b-Info.plist to target B, navigate to: Targets → B → Build Settings → Packaging → Info.plist File and set the value to Runner/b-Info.plist.

Note: Make sure this value is set for all build configurations.

linking new Target “B” with b-Info.plist

Step 4: Navigate to Project → Runner → Info in Xcode. Here, you'll see the three default build configurations: Debug, Release, and Profile. Click the "+" button below the configuration list to duplicate each configuration. After duplicating, it's recommended to rename all configurations using the format: BUILD_CONFIG–TARGET_NAME

Before:

guide on how to add new build configs

After:

None

Step 5: Next, navigate to Product → Scheme → Manage Schemes… to manage and configure your schemes.

opening Manage Scheme window in Xcode

Click the "+" button and enter the new scheme name. Make sure target B is selected.

creating new scheme

Now you should see the new Runner scheme (you can optionally rename it to "A" for consistency) along with scheme B in the scheme list. Double-click the scheme B to open the configuration window. On the left panel, you'll see the options Build, Run, Test, Profile, Analyze, and Archive, each linked to a specific build configuration.

Update these settings so that they point to the corresponding build configurations for target B. In our example, all occurrences of Debug-a and Release-a should be replaced with Debug-b and Release-b, respectively (see image below).

Repeat this step for scheme A/Runner as well to ensure all schemes are correctly configured.

Before:

configure scheme window

After:

  • Scheme B:
expected result of scheme B
  • Scheme A:
expected result of scheme A

Step 6: Now we edit our ios/Podfile in AndroidStudio

Before:

...

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

...

target 'Runner' do
  use_frameworks!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

...

After:

...

project 'Runner', {
  'Debug-a' => :debug,
  'Profile-a' => :release,
  'Release-a' => :release,
  'Debug-b' => :debug,
  'Profile-b' => :release,
  'Release-b' => :release,
}

...

# Shared pods for both targets
def shared_pods
  use_frameworks!
  use_modular_headers!
  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

# Runner target (a)
target 'Runner' do
  shared_pods
end

# b target
target 'B' do
  shared_pods
end

...

Note: To ensure these changes take effect, run pod install inside the ios directory and reopen Xcode afterward.

Step 7: The next step of the setup is to link the appropriate .xcconfig files generated by the Podfile to their respective build configurations. Once completed, the configuration should look like this:

final expected build configuration

Step 8: The penultimate step is to set up and assign a dedicated app icon for each target.

Assets folder should contain AppIcon sets for each target

Once the AppIcon sets are added to assets for each target, assign the correct set name in the App Icon field under Targets → General → App Icons and Launch Screen for each target:

linking correct app icon from assets to each target

Step 9: The final phase is to change value of FLUTTER_TARGET settings to its own dart entry points (main_a.dart and main_b.dart, in our case) instead of main.dart. We can change this value from Targets → Build Settings → User-Defined (you can search "FLUTTER_TARGET" in search bar to easily find).

Before:

FLUTTER_TARGET value for default Runner target
FLUTTER_TARGET value for B target

After:

expected FLUTTER_TARGET value for default Runner target
expected FLUTTER_TARGET value for B target

Step 10: With the all configuration in place for iOS, it's time to test our setup.

testing final config and setup in ios
None

Conclusion

Setting up flavors, targets, schemes, and build configurations may take extra time upfront, but it is highly cost-effective in the long run. Once your white-label infrastructure is in place, adding new clients or brands becomes a predictable, scalable, and low-maintenance process, saving countless hours of duplicated work down the line.

Although this article covers the Android and iOS setup for a multi-flavor/scheme environment, the next part of the series will focus on correctly configuring Firebase for white-labeled apps.

You can also find the complete example code on GitHub