Not every Flutter project starts on an empty repository. Most teams meet Flutter halfway through the life of a product they cannot rewrite — a five-year-old Android app, a UIKit codebase that ships to thousands of users, a marketing site nobody wants to touch. Flutter's add-to-app path lets you embed Flutter inside that existing host so a single feature can be rebuilt without putting the whole product on hold.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Get 85% off on UdemyThis tutorial is for engineers and tech leads doing exactly that — adding Flutter to a real Android, iOS, or web codebase for the first time. We will cover the module setup, both platform integrations, the engine-lifecycle decision (single cached engine versus FlutterEngineGroup), hybrid back-stack patterns, the MethodChannel contract that survives a year in production, and the five integration bugs that catch every team — blank fragments, broken hot reload, missing assets, app-size shock, and double-pop navigation.
If you are deciding between Flutter, React Native, or staying native for a greenfield project, the Flutter cluster hub is a better starting point. This page assumes you have already chosen Flutter and the question is now how to get it into an app that already exists. Tested with Flutter 3.27, Android Gradle Plugin 8.6, Xcode 15.4, and the official add-to-app workflow as documented in March 2026.
When add-to-app earns its keep
Add-to-app is the right tool when the host app needs to keep shipping while one feature gets rebuilt with modern UI capabilities. It is the wrong tool when the real goal is to migrate the whole product — at that point a parallel rewrite is usually cleaner.
- Modernise one feature without a freeze. A bank app rebuilds its onboarding flow in Flutter to ship animated identity verification while the rest of the app stays in UIKit and Jetpack Compose. The native team keeps releasing weekly; the Flutter team owns one screen pack.
- Validate Flutter before betting on it. A retail app rebuilds the product-detail page in Flutter for one A/B variant. If conversion holds and the team likes the workflow, the next feature follows. If not, the variant gets pulled and the cost stays bounded.
- Share one experience across two host apps. A loyalty programme runs inside a grocery app and a petrol app. Building the loyalty screens once as a Flutter module and embedding it twice keeps the two host apps owned by their original teams while collapsing the loyalty surface to one codebase.
Prerequisites
Add-to-app has a longer setup checklist than starting fresh. Confirm each item before opening any IDE — most failed integrations trace back to a skipped step here.
- Flutter SDK 3.16 or newer. Older versions ship an older add-to-app engine API;
FlutterEngineGroupin particular has had behaviour changes through 2024 and 2025. - Android side: Android Gradle Plugin 8.0+,
compileSdk34+, AndroidX migrated, Java 17 toolchain. Add-to-app on a project still on the Android Support Library will not build. - iOS side: Xcode 15.0+, iOS deployment target 12.0+, CocoaPods 1.13+ if you take the source-pod route. If the host iOS app uses Swift Package Manager exclusively, plan for the xcframework path instead.
- Web side: Flutter 3.10+ for Element Embedding API. Older versions force the Flutter app to take over the entire viewport, which usually defeats the point of embedding.
- Native team approval. Add-to-app changes the host app's build system. If the native team learns about it from a pull request the integration usually fails — get the architectural decision agreed before writing code.
Step 1: Create the Flutter module
A Flutter module is a normal Flutter project compiled in a way that lets a host app embed it. Create it as a sibling directory to the host app, not inside it — keeping the module beside the host avoids accidental commits of host artefacts into the Flutter repository.
cd ~/projects
flutter create --template module checkout_flutter_module
cd checkout_flutter_module
flutter pub get
flutter runThe flutter run step matters: a module can run on its own as a thin demo app, which is the fastest way to develop UI before you wire it into the host. Once the screen looks right inside the demo, integration becomes a deployment step rather than a debugging step.
Beginner pitfall: do not use the same Dart package name as a host-app library. If your Android host already has a Java package com.example.checkout, naming the Flutter module checkout will collide on Android resource lookups. Prefix module names (checkout_flutter_module) and you will save half a day later.
Step 2: Integrate with the Android host
Android offers two paths: a source dependency through Gradle (Flutter's documentation calls it the "auto-build" path) or an AAR dependency. Choose by where the Flutter team works.
- Source dependency. Add a one-line Flutter SDK hook to
settings.gradlethat points at the module directory. Every host build invokesflutter assemble, so changes to Dart code show up the moment Gradle syncs. Use this when the same engineers work on both sides daily. - AAR dependency. Run
flutter build aarinside the module to produce an Android Archive, then publish it to a Maven repository (Maven Central, Nexus, GitHub Packages). The host app declares it as a normal versioned dependency. Use this when the Flutter team and the native team are separate and ship on different cadences — the host upgrades only when it explicitly bumps the version.
Once the dependency is wired, host code launches Flutter through one of two entry points. FlutterActivity is the simpler path: replace a native screen launch with startActivity(FlutterActivity.createDefaultIntent(context)) and Flutter takes the whole screen. FlutterFragment is the more flexible path: embed Flutter inside an existing native fragment host, keep the toolbar native, and let Flutter own only the body. Pick one host pattern and stick to it inside a single feature — round-tripping between FlutterFragment and FlutterActivity with the same cached engine is a known source of blank-screen bugs (covered in Troubleshooting below).
Step 3: Integrate with the iOS host
iOS has the same source-versus-binary split with different names: a CocoaPods source pod, or an xcframework binary.
- CocoaPods source pod. Add the Flutter module's
podhelper.rbto the host'sPodfile. Eachpod installregenerates the Flutter framework, so Dart changes flow into the host on the next build. Same daily-team use case as the Android source path. - xcframework. Run
flutter build ios-framework --xcframeworkto produce a binary, vendor it into the host project, and version it. Use this when the host project uses Swift Package Manager, when CocoaPods is locked down, or when the Flutter team ships through a separate release channel.
Host code launches Flutter through FlutterEngine + FlutterViewController. Cache the FlutterEngine in your AppDelegate at launch — the first cold start of an engine takes hundreds of milliseconds, but a pre-warmed engine renders the first frame in tens of milliseconds. The trade is roughly 6 to 10 MB of memory held even when the user is not on the Flutter screen, which on a modern device is a fair price for the perceived speed.
Step 4: Choose an engine lifecycle — single engine vs FlutterEngineGroup
This is the decision most teams get wrong on the first attempt. There are two patterns:
- Single cached engine. One
FlutterEnginecreated at app launch, kept alive inApplication(Android) orAppDelegate(iOS), reused for every entry into Flutter. The simplest pattern. Hot reload works. Asset loading is predictable. Memory cost is one engine. Use this for one Flutter feature surface. - FlutterEngineGroup. A factory that spawns multiple engines from a shared snapshot. New engines start in tens of milliseconds (versus hundreds for a fresh cold start) because they share the same isolate snapshot, ICU data, and read-only resources. Use this only when you need two or more independent Flutter screens that may run side by side — for example, two tabs both rendered in Flutter, or a Flutter-rendered notification overlay running while a Flutter-rendered settings screen is open.
The trade with FlutterEngineGroup is that hot reload, asset loading, and engine destruction all behave differently. Hot reload only works for the engine your IDE is attached to; the others continue to run the original code until the app restarts. AssetManifest.json loading has known issues across multiple spawned engines (Flutter GitHub issue 87173 and 120870 document the failure modes). Engine destruction is manual: an engine spawned from a group has no automatic teardown, so leaking them costs memory until the app process dies.
Rule of thumb: start with a single cached engine. Only move to FlutterEngineGroup when you can name a specific feature that needs two engines simultaneously, and budget a day for the asset and lifecycle quirks.
Step 5: Hybrid navigation — decide who owns the back stack
The hardest add-to-app bug is not a build failure — it is the back button doing the wrong thing. The cause is almost always that the host activity stack and the Flutter Navigator stack both push routes for the same user action, and on back-press both pop. Fix it by deciding which side owns the stack before writing the first navigation call.
- Native owns the stack (recommended for shallow integration). Each native screen launches Flutter for one self-contained flow that returns a result. Flutter never pushes its own routes; the user closes the Flutter view by completing or cancelling the flow. This is the pattern for a single-screen integration like a payment confirmation or an identity-verification step.
- Flutter owns the stack (recommended for deep integration). Native launches Flutter once and Flutter
Navigatorhandles every onward transition. The native back button maps to Flutter'sNavigator.maybePopviaWillPopScopeon Android (or the equivalent gesture on iOS). This is the pattern for a multi-screen feature like a checkout flow with cart, address, payment, and confirmation pages.
The mistake is mixing the two — native pushes a screen, Flutter pushes a screen, the user hits back, both sides pop, and the user lands two screens away from where they expected. Pick one model for the entire feature and document it.
Step 6: Define the communication contract
Native and Flutter cannot read each other's memory. Every value that crosses the boundary travels through one of three channels:
- MethodChannel — request and response. The native side calls
invokeMethod("getAuthToken"), Flutter responds with aString, both sides return to their normal flow. Use it for one-shot lookups: session id, theme tokens, feature flags. - EventChannel — push streams from native to Flutter. Use it for location updates, push notifications arriving while the Flutter screen is open, connectivity state, biometric prompts that resolve asynchronously.
- BasicMessageChannel — raw payloads (binary, custom codecs). Most teams will never need this; reach for it only when MethodChannel's standard codec cannot describe the payload.
Treat each channel name as a versioned API contract. Use a namespaced channel name like com.yourcompany.checkout/v1, document the payload schema in a file shared between the two repos, and never silently change argument types — the Flutter side will fail at runtime, not compile time, and the bug will only surface in production where the host app is on an old release. When you genuinely need to break the contract, ship the new behaviour on v2 and keep v1 alive for one host-app release cycle.
Step 7: Embed Flutter inside an existing web app
Web add-to-app works through the Element Embedding API: build the Flutter web app pointing at a target element, mount it inside an existing HTML page, and the rest of the page continues to render normally.
flutter build web --output build/checkout_widgetThen in the host page, add a target div and bootstrap Flutter into it:
<div id="flutter-checkout" style="width:100%;height:600px"></div>
<script src="/checkout_widget/flutter.js" defer></script>
<script>
window.addEventListener('load', () => {
_flutter.loader.loadEntrypoint({
hostElement: document.querySelector('#flutter-checkout'),
onEntrypointLoaded: async (engineInitializer) => {
const appRunner = await engineInitializer.initializeEngine({
hostElement: document.querySelector('#flutter-checkout'),
});
await appRunner.runApp();
},
});
});
</script>The host page keeps its own routing and styling; Flutter renders only inside the target element. For more on the rendering choices that affect bundle size and motion fidelity, see Flutter Web: skwasm vs CanvasKit.
Worked example: rebuilding a legacy onboarding flow
A 4-year-old fintech app on Android (Java + Jetpack Compose) and iOS (UIKit + SwiftUI) needs to ship a redesigned onboarding flow with motion, animated illustrations, and shared design tokens between the two platforms. Native rebuild estimate: 14 weeks across both teams. Add-to-app estimate: 7 weeks for one Flutter engineer plus 2 weeks of native integration work per platform.
The team scoped the Flutter boundary to onboarding only — the native app launches a single FlutterActivity on iOS and Android with input parameters (referrer, partner id, locale) and listens for one of three completion events: completed, abandoned-can-resume, or abandoned-cannot-resume. The communication contract was four MethodChannel methods (getSessionContext, verifyDocument, finishOnboarding, analyticsEvent) plus one EventChannel for biometric callbacks. Engine model: single cached FlutterEngine warmed at app launch.
Outcome after 8 weeks: onboarding completion rate went from 64% to 71%, app size grew 5.4 MB compressed on Android (per ABI, after split APKs) and 9.1 MB on iOS. The Flutter team owns onboarding and ships independently every two weeks; the native team owns the rest of the app and ships weekly. The seam is one launcher and one channel namespace, which is small enough that rolling back to the native onboarding flow remains a real option.
Ownership and release cadence — the team-shaped half of the work
Add-to-app changes who is responsible for what after launch. Decide each of the following before the integration ships, not after:
- Who owns the seam. One named engineer (or pair) on each side owns the channel contract. When a payload field changes, both of them sign off. Without this, contract drift accumulates silently.
- Release coupling. Does a Flutter-side bug fix wait for the next host release, or does the host app embed a forced update for the Flutter module? Both are valid; the wrong answer is leaving it ambiguous.
- Bug routing. A user reports a crash on the onboarding screen. Does it go to the native triage queue or the Flutter triage queue? Decide on a routing rule based on the channel namespace in the crash report — a crash inside
com.yourcompany.checkout/v1routes Flutter, anything outside routes native. - Shared design tokens. If the host app and the Flutter module both render UI with the same brand, decide how colour and typography tokens stay in sync. Three reasonable answers: bake tokens into the module at build time, fetch them from a remote config service at runtime, or pass them in through the launching
MethodChannelcall. Pick one.
Common mistakes
- Wiring add-to-app before the native team agrees. The integration touches the host build system. Surfacing it in a pull request after the work is done is the fastest way to have it reverted.
- Sharing a single cached engine across
FlutterFragmentandFlutterActivity. Round-tripping is a known source of blank-screen bugs (Flutter issue 95877). Pick one host pattern per feature. - Calling
invokeMethodwithMap<String, Object>arguments and assuming all Dart side types pass through. Standard codec only carries primitives, lists, and string-keyed maps. Passing a domain object silently fails — wrap into a JSON string at the boundary. - Forgetting to handle "Flutter is loading" on slow devices. Even a pre-warmed engine takes a frame to draw. Show a native splash inside the Flutter host until the first Flutter frame;
FlutterFragmentActivityexposes asplashScreenoverride exactly for this. - Treating the Flutter module as a separate app. If onboarding analytics events do not go through the host's analytics SDK, they will not match the rest of the funnel. Bridge analytics through a
MethodChannelso every event lands in one place.
Troubleshooting
FlutterFragment goes blank after navigating away and back. Cause: the fragment detached from its
FlutterEnginewhen the host activity tore it down, and the engine no longer has a render surface to draw into. Fix: useFlutterFragment.withCachedEngine("checkout_engine_id")and create the engine once in yourApplicationclass withFlutterEngineCache.getInstance().put("checkout_engine_id", engine). If you genuinely want a fresh engine each visit, build withdestroyEngineWithFragment(true)instead — but never round-trip the same cached engine betweenFlutterFragmentandFlutterActivity.Hot reload stops working when you switch to FlutterEngineGroup. Cause: hot reload attaches to one specific engine, and a group spawns new engines that the IDE does not know about. Fix: during development, override
provideFlutterEngineto return your single cached engine even when the production build uses the group. Wrap the group code path behind aBuildConfig.DEBUGcheck so dev keeps hot reload and prod gets the multi-engine performance."Unable to load asset: AssetManifest.json" with FlutterEngineGroup. Cause: each spawned engine resolves assets through its own asset bundle path; secondary engines sometimes resolve to a path that does not contain the manifest yet (Flutter issues 87173 and 120870). Fix: explicitly call
engine.assets.path = io.flutter.embedding.engine.loader.FlutterLoader.findAppBundlePath()immediately after spawning each engine, and verify the path is non-null before callingrunApp.iOS app size jumps 50+ MB after adding the Flutter framework. Cause: the host project is shipping the debug Flutter framework or a fat framework that includes simulator slices. Fix: build with
flutter build ios-framework --xcframework --no-debug --no-profile, then verifyotool -hv Flutter.xcframework/ios-arm64/Flutter.framework/Fluttershows onlyarm64for App Store builds. A correctly built release xcframework adds 8 to 11 MB, not 50+.Hybrid back-stack double-pops on Android. Cause: both the host activity and the Flutter
Navigatorreacted to the back button — the user pressed once and lost two screens. Fix: pick one stack owner (Step 5). If Flutter owns the stack, wrap the root withWillPopScope(onWillPop: () => Navigator.of(context).maybePop())so the system back is intercepted by Flutter; if native owns, do not push routes inside Flutter at all and use a single-screen Flutter UI per launch.
When add-to-app is the wrong answer
Three situations make add-to-app a worse choice than the alternatives:
- You are starting a new app. A greenfield Flutter app skips every integration cost in this tutorial. Flutter vs React Native in 2026 and the Flutter cluster hub are better starting points.
- You are migrating the whole product. If the plan is to retire the native app, a parallel rewrite is usually faster than incremental add-to-app — the integration work is throwaway in a full migration.
- The host app's build system cannot host Flutter cleanly. Very old Android projects on the Support Library, or iOS projects with hand-rolled build configurations, will fight the Flutter Gradle and CocoaPods integration in ways that cost more than they save. Fix the host build system first, then add Flutter.
FAQ
Should I use a single FlutterEngine or FlutterEngineGroup for add-to-app?
Use a single cached FlutterEngine for one Flutter feature surface — it boots once, stays warm, and keeps memory predictable. Use FlutterEngineGroup only when you need two or more independent Flutter screens that may run side by side or quickly come and go, because it lets new engines spawn from a shared snapshot in tens of milliseconds instead of hundreds. The trade-off is more moving parts: hot reload, asset loading, and engine destruction all behave differently with a group, so reach for it only when the single-engine path stops fitting.
Will Flutter inflate my app size after add-to-app integration?
Yes — expect roughly 4 to 6 MB compressed on Android (per ABI) and 8 to 11 MB on iOS for a minimal Flutter module. Mitigate by enabling app bundle ABI splits on Android (the user only downloads one architecture), using thin xcframework slices on iOS, and deferring heavy plugins until after the user enters the Flutter feature. The size cost is real but smaller than most teams fear, and it does not grow linearly with each new Flutter screen because the engine is shared.
How do I share data between native code and the embedded Flutter screen?
Use MethodChannel for one-shot request and response (login session, user id, theme tokens), EventChannel for streams the native side pushes (location updates, push notifications, connectivity changes), and BasicMessageChannel only for raw binary payloads. Treat each channel name as a versioned contract: pick a stable namespace like com.yourcompany.checkout/v1, document the payload schema in a shared file, and never silently change argument types — the Flutter side will fail at runtime, not compile time.
Why does my FlutterFragment go blank when the user navigates away and comes back?
This is the most common add-to-app bug on Android. It happens because FlutterFragment is no longer attached to its FlutterEngine after the host activity tears it down. Fix it by either using FlutterFragment.withCachedEngine and keeping the engine alive in your Application class, or by calling destroyEngineWithFragment(true) when you genuinely want a fresh engine each visit. Round-tripping between FlutterFragment and FlutterActivity with the same cached engine has a known issue — pick one host pattern and stick to it inside a single feature.
Can I roll back add-to-app if it does not work out?
Yes, and a good integration is designed so this is easy. Because the Flutter module sits behind a defined seam (one entrypoint route, a fixed MethodChannel surface, an explicit input and output payload), removing it means swapping the host launcher back to the original native screen and deleting the module dependency. The harder rollback is the team one — if shared models, release cadence, and bug ownership crossed the seam, untangling those takes longer than the code change.
Sources
- Flutter docs — Add Flutter to an existing app (official)
- Flutter docs — Integrate a Flutter module into your Android project
- Flutter docs — Integrate a Flutter module into your iOS project
- Flutter docs — Element Embedding API for web
- Flutter docs — Writing custom platform-specific code (MethodChannel)
Related tutorials
- The Flutter Guide — the cluster hub: which tutorials cover what and where add-to-app fits in the bigger Flutter learning path.
- Flutter App Architecture in 2026 — feature-first structure for the Dart side of the module so it stays maintainable as the seam grows.
- Responsive Flutter UI for Mobile, Tablet, Desktop, and Web — make the embedded screen adapt to every host viewport without per-platform forks.
- go_router in Flutter — deep-linking from a native host launcher into a specific Flutter route.
- Flutter Web: skwasm vs CanvasKit — pick the right web renderer when embedding Flutter inside an existing web app.
- Flutter vs React Native in 2026 — read this if you have not yet committed to Flutter for the integrated feature.