Skip to content
FlutterFlowFlutter
Migration Guide

FlutterFlow to Native Flutter: Rewrite Guide

FlutterFlow is a fast way to ship a v1 — and a slow way to ship a v3. This is how I move apps from generated FlutterFlow code to a maintainable hand-written Flutter codebase, and how to tell when it's time.

What Changes
  1. Generated widget trees become hand-structured, reusable widgets
  2. FlutterFlow app state becomes a real state-management layer (Riverpod/Bloc)
  3. Visual Firestore bindings become typed repositories and models
  4. Custom Actions/Functions merge into the main codebase as normal Dart
  5. The FlutterFlow project stops being the source of truth — git is
Migration Path

Export the code, but don't refactor it in place — the generated structure fights every abstraction you add. Rebuild screen by screen in a fresh project against the same Firebase backend, so both apps run against identical data while the rewrite is in progress. The backend not changing is what makes this a rewrite you can ship incrementally.

In detail

When FlutterFlow stops being the right tool

The signals repeat across projects. Custom Actions and Custom Functions start outnumbering visual logic — once a meaningful share of your behavior lives in escape-hatch Dart code, you're already writing Flutter, just inside a harness that makes it harder. Merge conflicts between team members editing the same page in the visual editor become routine. Performance work hits a wall because you can't restructure the generated widget tree — memoization, granular rebuilds, and custom render logic all assume control of the tree you don't have. And each FlutterFlow platform update can subtly change generated output, so your app's behavior shifts without a commit.

None of this means FlutterFlow was the wrong starting choice. Callidus exists because a clinic's FlutterFlow build had stalled — and the replacement shipped in about ten weeks precisely because the product was already validated. That's the pattern: FlutterFlow answers "should this product exist" cheaply; native Flutter answers "how does this product survive three years of feature requests". The rewrite decision is about which question you're currently asking.

Why refactoring the export in place fails

FlutterFlow's code export is real Flutter and it compiles — but it is generated for the machine, not for maintenance. Every page is a monolithic widget file with deeply nested builders, state lives in generated model classes wired to the page lifecycle, theming routes through FlutterFlow's own theme layer, and naming follows the visual editor's conventions rather than yours. Teams that try to refactor this in place spend weeks fighting the structure: every extraction breaks a binding, every renamed model touches dozens of generated references, and the moment someone regenerates from FlutterFlow, manual edits are overwritten.

Treat the export as a specification instead. It tells you exactly what every screen does — every query, every conditional visibility rule, every navigation edge — with none of the ambiguity of a design file. The rewrite then becomes transcription rather than archaeology: read the generated page, rebuild it as three or four composed widgets with a proper state layer, and check behavior against the running FlutterFlow app side by side. Screens rebuild in hours this way, because the hard product decisions were already made in the visual editor.

The backend is your bridge

The single decision that makes this migration low-risk: keep Firebase exactly as it is. FlutterFlow apps sit on ordinary Firebase — Firestore collections, Firebase Auth, Storage buckets, Cloud Functions — and none of that is FlutterFlow-specific. The rewritten Flutter app talks to the same project, same collections, same security rules. That means both apps are fully functional against live data throughout the rewrite, your existing users' accounts and documents carry over with zero data migration, and you can ship the new app to internal testers while the FlutterFlow app is still what's in the stores.

Replace FlutterFlow's generated Firestore bindings with a thin typed layer: a model class with fromMap/toMap per collection and a repository per feature. Resist the urge to redesign the Firestore schema during the rewrite — schema changes multiply risk and break the running FlutterFlow app. Note them, ship the rewrite against the existing schema, and migrate data as a separate later project. One codebase change at a time is what keeps a rewrite shippable.

Sequencing the rewrite so it ships

Set up the skeleton first: a fresh Flutter project with routing (GoRouter), state management (Riverpod fits Firebase's stream-heavy model well), the Firebase config for the existing project, and the theme translated from FlutterFlow's design system into a plain ThemeData. Then rebuild in dependency order — auth flow first, because everything sits behind it, then the main navigation shell, then screens ordered by how much of the app's value they carry. High-traffic screens early: they're where hand-written Flutter pays off fastest and where side-by-side testing against the FlutterFlow app matters most.

Custom Actions and Custom Functions port almost verbatim — they were plain Dart all along — but read them critically, since escape-hatch code written around FlutterFlow's constraints often simplifies once those constraints are gone. Plan the store transition deliberately: same bundle ID and signing keys means the rewrite ships as a version update, not a new app, so reviews and install base carry over. And keep the FlutterFlow project alive but frozen until the rewrite has been stable in production for a cycle — it's your reference implementation and your emergency fallback in one.

Live ExampleCallidus — Case Study
Other migration guidesView all →
Related

Planning this migration?