Planning the migration to Flutter

How to pick the first screen to migrate, roll out in phases, handle store review, and plan the exit to a pure Flutter app from day one.

What to rewrite first?

The choice of the first screen to migrate matters more than any screen after it. If it goes well, the team ships something real, gains confidence, and validates the approach in front of users. If it goes badly, you can end up with months of integration work that nobody scoped and still no Flutter in production - and every skeptic of the migration gets an easy argument.

Criteria for a good first screen

Look for a screen that stands on its own:

  • Self-contained - few native dependencies and little data needed from outside the screen itself. Settings, profile, and onboarding flows are the classic examples.
  • A brand-new feature that doesn't exist on native yet - you get a fresh start with nothing to match pixel-for-pixel, though you also lose the side-by-side comparison that can win over stakeholders.
  • Looks the same on iOS and Android - one Flutter implementation covers both platforms, which is the case Flutter handles best. Screens that have drifted apart between platforms are also good candidates if you want to bring them back to a single UI.
  • A leaf in the user journey - the user lands there and returns to native afterwards, instead of moving on to yet another native screen. Every crossing between native and Flutter adds friction you'd rather not take on while the team is still learning.
  • No embedding - a full-screen Flutter view is much simpler than mounting Flutter inside a native view or the other way around. It's better to leave embedding for after your first full-screen migration ships.
  • Meaningful traffic - high-visibility screens give you honest feedback quickly and make a stronger case for stakeholders.

Criteria for a poor first screen

The screens to avoid at first are the ones where most of the work happens at the native-Flutter boundary:

  • Deeply tied to native SDKs, such as camera, maps, AR, audio, or biometrics - most of the effort goes into passing data across the boundary, which is the one part of add-to-app you don't want to be learning on day one.
  • Dependent on platform-specific navigation - shared-element transitions, custom modal presentations, animations that cross screen boundaries. These problems are usually solvable, but the solutions tend to be expensive and a bit hacky, and they will slow you down before you ship anything.
  • Requires embedding - Flutter inside a native view, or a native view inside Flutter. Embedding brings lifecycle, gesture, and sizing problems you don't need at this stage; the Embedding page explains them and the alternatives.
  • In the middle of a long native journey - you pay the crossing cost every time the user moves forward or back. If you have to touch such a screen, it's often better to migrate the whole journey at once than to put one Flutter screen between native ones.
  • Part of authentication - login, registration, session handling. Every other flow sits behind it, it is coupled to the native auth stack, and a bug there locks users out of the whole app. One team we interviewed started with the login page and, in hindsight, would have started with screens that don't require authentication at all.

Decision flow

Patterns we've seen

Between our own projects and the migration teams we've interviewed, a few patterns keep coming back.

1. The new business module in Flutter

A common case is a team adding a brand-new module to a mature app - a loyalty program inside a retail app, a B2B portal, a vertical that simply didn't exist before. The module has its own flows and state, its own data, and sometimes even talks to a different backend. The user enters it from one place (a card on the home screen, a tab, a deep link) and stays inside until they leave. From the perspective of the rest of the app, it's a black box with a single entry point.

This is the case add-to-app handles best. The module is effectively a small standalone Flutter app that happens to live inside a native shell, so the boundary work concentrates at that single entry point: you pass some data in at the start and hand some back on the way out. The team can move fast inside the module and rarely has to touch the surrounding native code. When the work is a self-contained vertical with one entry point, you get most of the benefits of a pure Flutter app without leaving the native one.

2. The high-impact gamble - when the business picks the most central screen

One team we interviewed, at a large marketplace company, had the engineering side arguing for starting from leaf screens - they had prepared a migration roadmap that worked inward from the edges. The business decided otherwise. The app has several hundred screens, and if only a handful were going to be Flutter at first, the business wanted the three or four most-visited places rewritten before anything else. So the home screen became the first thing to rewrite.

Unfortunately, the home screen was also the most connected screen in the app - fed by account state, search, recommendations, ads, and deep links - and each of those connections became a native-Flutter contract to specify and maintain. To ship it at all, the team had to embed native widgets inside the Flutter screen, because their internal advertisement library couldn't be reimplemented in Flutter. The company came out of this experience with a strong internal policy: avoid embedding native in Flutter or Flutter in native (see Embedding for why, and what to do instead). The core migration, meanwhile, is still in its early stages.

The most visited screen is usually also the most connected one, and each of those connections is a contract that someone has to specify and maintain. If the business wants the front page first, make sure this cost is part of the discussion.

3. The leaf-first gradual migration

A third team went the other way and started with leaves - terminal pages that users land on and return from - plus greenfield features with no native predecessor. Their reasoning was the boundary cost. A leaf screen has exactly one entry and one exit, so the only contract is the data you pass in and the result you hand back. There are no deep links to intercept mid-flow, no stacking of native on Flutter on native, and no shared state to keep in sync while the user moves around. Greenfield features were even cleaner, since there was nothing to replicate and no behavior to match.

Because each migration was small and shippable on its own, no single setback could threaten the whole program. The team proved the pattern on low-risk screens, built up the shared infrastructure (contracts, engine management, design-system components) piece by piece, and got everyone comfortable with the toolchain before touching anything central. Quarter by quarter they worked inward from the leaves, and by the time they reached the connected screens, the hard infrastructure was already in place and proven in production.

That app is now mostly Flutter, and the team is making the final switch - moving the shell itself to Flutter and retiring the native host. Starting from leaves was slower at the beginning, but it got them all the way there.

4. The "add-to-app will be safer" miscalculation

The last team inherited a native app in genuinely bad shape: years of legacy code, layered antipatterns, no current design references (no Figma, just the running app), and no documentation. The people who understood the original behavior had left long ago. The developer argued for a rewrite, but the client insisted on add-to-app because the incremental approach felt safer - keep the proven native code running and replace it screen by screen. There was no migration strategy beyond that, and the first screen picked was the login page - the one every other flow depends on.

The catch was that this "proven" native code was a black box. To migrate a screen, the team first had to reverse-engineer what it did - reading tangled native code to recover business rules that nobody could state anymore - and then reimplement the same quirks in Flutter so that both sides behaved identically. Most of their time went into replicating undocumented logic rather than building anything new. And because there was no preparation phase - no effort to document the behavior, untangle the worst parts, or establish design references first - every screen started that reverse-engineering work from scratch.

In this case, a rewrite would probably have been the better call. When the existing code is this opaque, add-to-app makes you pay twice: once to understand the old code and again to maintain the boundary on top of it. A rewrite pays the understanding cost once and drops the boundary entirely. The incremental approach is not automatically safer - when the native code is legacy, undocumented, and unowned, and there is no preparation phase to recover that knowledge, a clean rewrite can often be the lower-risk path.

Phased rollout

Phased rollout is what keeps the risk of add-to-app manageable. The most valuable screens are also the most expensive to migrate, and shipping one of them to every user on day one is asking for trouble. Add-to-app makes the alternative easy: gate each Flutter screen behind a feature flag (Firebase Remote Config, LaunchDarkly, Optimizely, or your in-house equivalent), and you get A/B testing and gradual rollout almost for free. If you add metrics behind the same flag, you can measure the real user-facing impact of the Flutter version before widening the exposure.

Two risks specific to add-to-app deserve extra attention during rollout.

Engine startup time

The first time Flutter initializes inside a native app, it takes a moment. The delay is not dramatic, but left unhandled it leaves the user looking at a blank screen. Native screens look snappier in side-by-side demos for a simple reason: a native screen has no runtime to spin up, while the first Flutter screen pays the cost of the engine's cold start. You can largely avoid this by prewarming an engine (typically on the splash screen) before the user reaches any Flutter screen, so that it renders immediately. Inlay does this for you on start() / init() - see engine prewarming. It's still worth keeping this metric on your dashboard, especially for lower-end devices.

Low-end device memory pressure

Running multiple Flutter engines without managing them properly can strain memory on cheaper devices, and you want to catch that early. Track the first-screen-render time and the crash rate as guardrails for the whole rollout - if memory pressure shows up, the feature flag lets you fall back to the native screen while you fix it.

Exit strategy: how to fully switch to Flutter

Staying in add-to-app forever has its limits. There is always some friction at the boundary, and your Flutter developers never get to use Flutter the way they would in a standalone app. The exit removes this limitation: you replace the native shells with a Flutter app that owns the shell and the navigation. After that, the app behaves like the standalone Flutter apps your team already knows how to build.

You don't need to reach 100% Flutter to make the switch. The threshold can be lower - it's mostly a question of which screens you're willing to leave native. Rarely visited screens, modules owned by another team, or parts too deeply coupled to native to be worth rewriting can all stay as they are. What changes is the direction of control: instead of a native shell hosting Flutter screens, a Flutter shell hosts the remaining native ones. The main question to answer is how the Flutter host keeps data in sync with those native modules. It's the same boundary work as before, just pointing the other way, and the shared state layer your framework already provides is what makes that synchronization manageable.

Plan for the exit from day one:

  • Track your migration percentage, so you always know how much of the app and how many user journeys are Flutter versus native.
  • Think in user journeys, not single screens. Migrating a few tightly coupled screens together is often easier than rewriting one and keeping the rest native, because the cost of integrating across the boundary can outweigh the cost of just finishing the journey.
  • Identify the "last mile" screens - the ones that will be hardest to migrate - and start researching them early, so you're ready when their turn comes.

Our add-to-app framework is designed with the exit in mind. The abstractions it provides (navigation, state) map cleanly onto patterns you'd use in a pure Flutter app, so removing the framework doesn't require massive rearchitecting of your Dart code. Our navigation layer supports easy integration with the most popular libraries (like go_router or auto_route), so most of it will stay the same when doing the exit. Similarly, the shared state layer can be easily detached from native platforms, keeping all your existing business logic practically unchanged.

Does add-to-app affect App Store / Play Store review?

No. Neither Apple nor Google prohibits embedding Flutter in a native app, and add-to-app projects go through the standard review process. Flutter compiles ahead of time into native ARM code rather than downloading and interpreting executable code, so it complies with Apple's rules, and we've never seen the presence of Flutter affect review times.

On this page