Building Smarter White-Label Apps with Modular SDKs

Summary
  • Explains how to build a modular white-label cross-platform app architecture that lets SDKs be enabled or disabled per client without increasing app size or compromising stability.
  • Outlines four challenges: managing dependencies at build time, excluding unused native code, handling dynamic dependency imports, and providing a unified abstraction layer.
  • Demonstrates a React Native solution using shell scripts for dependency management, Expo Prebuild and Config Plugins for conditional native code, dynamic SDK mapping files for safe imports, and an abstract analytics adapter pattern.
  • Notes that Flutter and bare React Native lack an equivalent to Expo Prebuild, requiring alternatives like patch scripts, wrapper modules, Android manifest merging, or XcodeGen when native code modifications are needed.

If you develop a white-label app, you’ve probably faced some challenges when a client asks for a specific SDK (Software Development Kit) for analytics, deep links, or other features, and wondered how to offer multiple SDK options to different clients without increasing your app’s size or compromising its stability. 

In this article, we’ll explore how to solve this problem specifically in cross-platform projects. Whether you’re building with React Native or Flutter, the goal is the same: to create a modular architecture that lets you enable or disable SDKs per client while keeping your white-label app lightweight, stable, and easy to maintain.

Challenges to Solve

When building a solution like that in a cross-platform white-label app, some challenges need to be addressed:

  1. Managing dependencies at build time: installing, removing, and configuring them per client.
  2. Ensuring unused native code is completely excluded to keep the app lightweight and avoid unnecessary complexity.
  3. Handling dynamic dependency imports so features can be toggled on or off without breaking the build or increasing coupling.
  4. Providing a unified abstraction layer so the app can interact with analytics, deep links, or other features without calling each SDK individually.

Read more: How to Create a React Native App Using Typescript

Managing dependencies at build time

One effective way to handle build-time dependency management is by using a shell script that dynamically installs or removes SDKs based on environment variables. Before the build starts, the script can read flags such as ANALYTICS_ENABLED, DEEPLINK_ENABLED, and any client-specific configuration, and run commands like yarn add or yarn remove accordingly.

This ensures that only the required SDKs are included in the project for that particular build.

Ensuring unused native code is completely excluded

In the React Native ecosystem, this challenge is greatly simplified thanks to Expo Prebuild and Expo Config Plugins. With it, you can generate native code that includes only the SDK plugins required for a specific build.

Below is an example using the Klaviyo SDK. When expo prebuild runs, it generates the native iOS and Android projects automatically, including all the Klaviyo configuration, so you don’t need to write or maintain any native code yourself.

By wrapping the plugin definition in an environment-dependent condition (process.env.KLAVIYO_ENABLED), the SDK is only included when required for that specific build. This lets each client’s app include only the native integrations they need, without adding extra code or unnecessary dependencies.

// app.config.js
{
  expo: {
    name: 'My app',
    ios: {...},
    android: {...},
    plugins: [
      process.env.KLAVIYO_ENABLED && [
        'klaviyo-expo-plugin',
        {
          version: process.env.APP_VERSION,
          ios: {
            buildNumber: process.env.APP_BUILD_NUMBER,
            bundleIdentifier: process.env.BUNDLE_ID,
            infoPlist: {
              UIBackgroundModes: ['remote-notification'],
            },
            badgeAutoclearing: true,
            codeSigningStyle: 'Manual',
            devTeam: process.env.APP_TEAM_ID,
          },
          android: {
            package: process.env.PACKAGE_NAME,
            openTracking: true,
            logLevel: 1,
            notificationIconFilePath: './assets/images/icon.png',
          },
        },
      ]
    ],
  }
}Code language: JavaScript (javascript)

If an SDK doesn’t provide an official Expo plugin, you can still take advantage of this workflow by creating your own custom plugin. Check this documentation for Expo Config Plugins mods.

Handling dynamic dependency imports

When you remove a dependency at build time, any direct imports of that package in your files fail since the library is no longer installed. This can break the build or cause runtime errors if not handled properly.

With that, one thing you can do is add to your shell script a capability to also create a dynamic SDK mapping file. For example:

// No SDK installed
const sdks = {
  firebase: undefined as any,
  klaviyo: undefined as any
}

export const firebase = sdks.firebase
export const klaviyo = sdks.klaviyo

export default sdksCode language: JavaScript (javascript)
// With the SDKs installed
import { Klaviyo } from 'klaviyo-react-native-sdk'
import { getAnalytics, logEvent } from '@react-native-firebase/analytics'

const sdks = {
  firebase: { getAnalytics, logEvent },
  klaviyo: Klaviyo
}

export const firebase = sdks.firebase
export const klaviyo = sdks.klaviyo

export default sdksCode language: JavaScript (javascript)


Then, rely on this file to handle the SDK imports safely, ensuring your code never breaks when a dependency is missing.

Providing a unified abstraction layer

Having a unified abstraction layer keeps your code clean and prevents you from calling each SDK directly. For example, if you have multiple analytics SDKs, you can create an abstract analytics adapter and a global analytics class that will handle all the necessary analytics methods.

The main Analytics class then decides which SDKs to load based on the configuration, while each SDK (like Klaviyo) just implements the shared contract.

abstract class AnalyticsAdapter {
  abstract logEvent(event: string, payload: Record<string, any>): void
  abstract initialize(config: any): Promise<void>
}

enum AnalyticsAdapterType {
  DEFAULT = 'default',
  KLAVIYO = 'klaviyo',
  ...
}

class Analytics extends AnalyticsAdapter {
  type: AnalyticsAdapterType = AnalyticsAdapterType.DEFAULT
  private adapters: AnalyticsAdapter[] = []

  logEvent(event: string, payload: Record<string, any>): void {
    this.adapters.forEach(adapter => adapter.logEvent(event, payload))
  }Code language: PHP (php)
async initialize(config: any): Promise<void> {
    // create adapter instances
    if (config.klaviyo.enabled && process.env.KLAVIYO_ENABLED) {
      this.adapters.push(new KlaviyoAnalytics())
    }

    // initialize all adapters
    this.adapters.forEach(adapter =>      
adapter.initialize(config[adapter.type]))
  }
}Code language: JavaScript (javascript)
import Klaviyo from 'sdks'

class KlaviyoAnalytics extends AnalyticsAdapter {
  type: AnalyticsAdapterType = AnalyticsAdapterType.KLAVIYO
  logEvent(event: string, payload: Record<string, any>): void {
    // ... Klaviyo SDK methods
  }
  async initialize(config: any): Promise<void> {
    Klaviyo.initialize(config.key)
  }
}Code language: JavaScript (javascript)

This way, the rest of your white-label app interacts with one unified interface.

How to do it on Flutter or bare React Native?

If your SDK doesn’t require custom native code, you are good. Just abstract the imports and the adapters’ logic.

However, if you do need to modify native files, things become more complex. In those cases, you can create scripts to patch Android and iOS files, build a wrapper module to encapsulate the native logic, rely on mechanisms like Android’s manifest merging, or even try to use tools like XcodeGen.

Unfortunately, there are no equivalent tools like Expo Prebuild that simplify this workflow for Flutter or bare React Native.

Read more: Getting you up to speed on Flutter versus React Native versus native development

FAQ

What problem does this article address?

It addresses how to offer multiple SDK options (for analytics, deep links, or other features) to different clients in a cross-platform white-label app without increasing the app’s size or compromising its stability. The goal is to create a modular architecture that lets you enable or disable SDKs per client while keeping the app lightweight, stable, and easy to maintain.

What are the main challenges when building this solution?

The main challenges are: managing dependencies at build time (installing, removing, and configuring them per client), ensuring unused native code is completely excluded, handling dynamic dependency imports so features can be toggled without breaking the build, and providing a unified abstraction layer so the app can interact with SDKs without calling each one individually.

How can build-time dependency management be handled?

By using a shell script that dynamically installs or removes SDKs based on environment variables. Before the build starts, the script reads flags such as ANALYTICS_ENABLED, DEEPLINK_ENABLED, and client-specific configurations, and runs commands like yarn add or yarn remove to ensure only the required SDKs are included in that build.

How does Expo help exclude unused native code in React Native?

Expo Prebuild and Expo Config Plugins allow you to generate native code that includes only the SDK plugins required for a specific build. By wrapping the plugin definition in an environment-dependent condition (like process.env.KLAVIYO_ENABLED), the SDK is only included when required, so each client’s app contains only the native integrations it needs. If an SDK doesn’t provide an official Expo plugin, you can create your own custom plugin.

How can this be approached in Flutter or bare React Native?

If the SDK doesn’t require custom native code, you can simply abstract the imports and adapters' logic. If native files need to be modified, you can create scripts to patch Android and iOS files, build a wrapper module to encapsulate native logic, rely on mechanisms like Android’s manifest merging, or try tools like XcodeGen. There are no equivalent tools to Expo Prebuild that simplify this workflow for Flutter or bare React Native.

About the author.

Leandro Pontes Berleze
Leandro Pontes Berleze

Mobile Developer who loves to play sports and watch Harry Potter.