Something in your app finally needed code. A native SDK nobody has wrapped, offline sync, a background task, a payment flow with a step the builder will not express, or a store reviewer asking a question you cannot answer from a visual editor. So you found the Export Code button. Then you found out it only goes one way.
You have probably also had the other conversation by now. Someone looked at the project, said the word rebuild, and attached a timeline to it that made your stomach drop.
Here is the short version before anything else. What comes out of that export is real Flutter. It compiles, it runs, and it is an ordinary Dart project. Your app is not a throwaway and this is almost never a rewrite.
What it is instead is a takeover. Takeovers have an order, and nearly all the cost people blame on FlutterFlow comes from doing that order backwards. This page is written for the week after the export rather than the week before it.
If you are on the other cross-platform stack, the same week has a different shape and what actually breaks when an Expo app goes to production covers it. If the export has you questioning the framework choice itself, Flutter against React Native, compared by a team that ships both is the better starting point. And if you got here from a different builder entirely, the general case is the same problem with a different vendor on the front of it.
What Do You Actually Get When You Export?
A standard Flutter project. Not a bundle, not an archive that needs FlutterFlow installed to open, not a proprietary format. Real Dart in a real pubspec, building with the same commands as any Flutter app written by hand.
Then you open lib/ and notice you also got something you did not ask for.
A helper library, vendored into your repository. FlutterFlow generates a flutter_flow directory holding theme definitions, navigation, shared widgets and utility code. It is the glue between your screens and everything underneath them. From the moment you export, that directory is yours. Nobody upstream patches it, nobody bumps it, and if a Flutter release changes something under it, fixing that is your afternoon.
One global state object. app_state.dart holds an FFAppState class, a ChangeNotifier carrying the global values you defined in the builder. Pages that need one of those values subscribe to it with context.watch. It is a normal Flutter pattern, applied globally, and the consequences of that show up in the performance section further down.
A widget file and a model file per screen. The widget holds the tree. The model holds the controllers and the per-page state that outlives a rebuild. Once you have seen the pattern on one screen you have seen it on all of them, which is genuinely useful when you are reading forty of them in a week.
A generated backend layer. Schema classes and query code written against whichever data source you connected. Your screens do not talk to the backend directly. They talk to this, and this talks to the backend, which matters a great deal if you ever want to change the backend.
So the accurate description of what you now own is not one app. It is one app plus a small framework you did not write and cannot upgrade from upstream. That reframe is worth holding on to, because most of the bad decisions in a takeover come from treating that framework as noise. Teams that open the helper directory in week one and start tidying it create most of the pain that later gets blamed on the export.
Why Is the Export One Way, and What Does That Cost You?
Because the visual project is the source of truth and the code is the output. Edit an output and there is nothing to feed back. That is not a gap in the product, it is what the product is.
There is more nuance here than the forums suggest, and the nuance is the useful part. FlutterFlow can keep writing generated code into a repository branch rather than a download, and the custom functions, actions and widgets you define inside the builder are part of the project, so they survive regeneration. That seam is real and most teams find it late. You can push the boundary of what the builder can do a long way before you ever leave it.
What no version of that flow does is absorb a change you made by hand to a generated file. Refactor a generated screen in your editor and the next generation either overwrites it or hands you a conflict. So the seam holds for code you add. It does not hold for code you change.
Which means the export is not a backup. People take it as one. They export a copy to feel safe, keep building in the builder, export again four months later, and now they have two histories of the same app that were never the same app. Reconciling those by hand is the single most expensive thing we see in this space, and it is entirely avoidable, because the only thing that causes it is treating a decision as a precaution.
The organisational cost is bigger than the technical one. In most FlutterFlow teams there is one person who is not an engineer and who has been shipping screens all year. A designer, a founder, someone in marketing who got good at it. The day you leave the builder, that person stops being able to ship. Their work now goes through a pull request and a developer. Nobody writes this into the migration plan and it is the change the team actually feels in week three.
Velocity dips before it rises. For the first few weeks after a takeover you are slower than you were, because you are setting up things the builder was doing for you. That is normal and it is recoverable. It is also the window in which somebody senior asks whether this was a mistake, so it is worth saying out loud before it happens rather than after.
The failure mode here is not dramatic. Nothing crashes and no data leaks. You simply find, six months on, that you are maintaining two versions of one app in two places and shipping properly in neither. That is a slow year, and slow years are hard to notice from the inside.
Why Is My FlutterFlow App Slow?
Work out which slow you mean first. Two completely different problems get answered with the same words online, and only one of them is fixed by anything on this page.
The builder is slow. That is a web editor working on a project that has grown, and it gets worse roughly in proportion to how many screens and components you have. Real, annoying, and unrelated to your users. Exporting because the editor feels sluggish solves nothing about your app, and you will have traded a slow editor for an owned codebase, which is a bad trade if that was the only reason.
The app is slow. Now we are somewhere useful, and the first place to look is rebuild scope. Global values in a single ChangeNotifier, read at page level, mean a change to any one value can rebuild a subtree that does not use that value at all. At five screens nobody notices. At forty screens with a list on most of them, users do. The fix is not to tear out the app state, it is to narrow what subscribes to it, starting with the two or three screens that feel worst.
After that, the usual Flutter suspects. Long lists built eagerly instead of lazily, so the framework constructs a hundred rows to show eight. Images decoded at their full source size and scaled down for display. Expensive work sitting in a build method where it runs on every frame that touches it.
Measure in a release build. This one matters more than the rest combined. Debug builds in Flutter are not representative of shipped performance, and a good share of everything written about Flutter being slow is a debug build being judged as though it were a shipping one. Profile the release build on a real mid-range device, not on the newest phone in the office.
Notice that none of the above is specific to FlutterFlow. It is ordinary Flutter performance work, on an ordinary Flutter app. Which is the most reassuring thing in this whole article: whatever the builder did or did not do for you, what you are holding now behaves like every other Flutter codebase, and everything the wider Flutter community knows applies to it directly.
What Does Custom Code Let You Do, and Where Does It Stop?
Nearly anything, right up to the point where your own data types are involved.
The toolkit is wider than people assume. Custom functions for synchronous logic. Custom actions for anything asynchronous, which in practice means anything Dart can do. Custom widgets when the built-in components will not produce what your designer drew. And package dependencies declared inside the builder, so the pub ecosystem is available to you without leaving.
The boundary is the type system. Inline expressions accept a narrow set of argument types and cannot see the custom data types you defined in the project. So the moment your logic needs your own model objects rather than strings and numbers, the work moves into a custom action. That is a perfectly good answer and plenty of production apps live there happily.
It is also a signal, and this is the part worth sitting with. Once most of your logic lives in custom actions, the builder is no longer building your app. It is holding your screens and calling your code. Nothing is wrong with that arrangement, and it can run for years. But the question of whether you should export has quietly been answered by how the project already works, and the only thing left to decide is when.
There is a practical test that takes an afternoon. Open the project and count what fraction of your business logic is in custom actions versus expressed visually. If it is most of it, you are paying for a visual builder you have mostly stopped using visually, and the honest conversation is about timing rather than direction.
What Breaks When You Submit to the App Store?
Nothing specific to FlutterFlow, which is the part that surprises people who expected the builder to be the problem.
The rejections are the ordinary mobile ones, and there are four that account for most of them.
Signing and identifiers. The bundle identifier in the project has to match the record in the store, and the signing setup has to be something your machine or your CI can actually reproduce. This is the most common first-submission failure in mobile generally and it has nothing to do with how the app was built.
Permission purpose strings. A reviewer reads the sentence that appears when your app asks for the camera, the microphone or location. If it is missing, or if it is generic enough to be meaningless, that is a rejection with a human comment attached. Set these deliberately, in the words a user would understand, not in the words a developer would write.
The privacy manifest. Apple expects a declaration covering required-reason API usage, and this catches teams out because it is not only about your code. A package you pulled in can trigger the requirement on your behalf, which means the dependency you added for one screen can hold up a release.
The Play data safety declaration. It has to describe what the app actually transmits, not what you intended it to transmit. Analytics and crash reporting count. Mismatches here are found later than the others and are more irritating to fix, because by then the release is already in review.
After export, the native folders are yours. The ios and android directories are ordinary parts of your repository now. That is the good news and the work in one sentence. Signing, version numbers and release automation become things your team owns, and you will want them automated rather than done from somebody's laptop. Codemagic is the documented route for Flutter and it is a reasonable default, though any CI that can build Flutter will do.
One scheduling rule saves more grief than anything else here. Do not do the export and the first store submission in the same week. If a review comes back rejected, you want exactly one candidate explanation. Export, get the exported project building and signing in CI, and ship one release from the new pipeline that is functionally identical to the last one. Then start changing things.
Is the Exported Dart Any Good?
It is real, it compiles, and it carries the signature of generated code. Both halves of that sentence are true and most writing on the subject only quotes one of them.
The signature looks like this. Naming mirrors what you typed in the builder rather than your domain, so a screen you called something provisional in month one is still called that in the code. Widget trees nest deeply, because the builder emits a tree per screen and never refactors one into components on your behalf. Logic ends up reachable from global scope in places where a hand-written app would have hidden it. And there are no tests. Not few. None, because FlutterFlow does not generate any, and that is the largest genuine gap in what you inherit.
Now read that list the way you would read it about any codebase you were inheriting from another team. It is unremarkable. Plenty of hand-written apps are considerably worse to take over, and generated code has one large advantage over a bad hand-written codebase: it is consistent. Every screen is wrong in the same way. That makes correcting it closer to running a script than to doing archaeology, and it is why takeover estimates for exported projects are usually tighter than estimates for inherited human code.
What to change first. Three things, in this order. Put an interface in front of the data layer, because it is the only early change that buys you options on the backend question and it does not touch a single screen. Add tests around whatever costs money when it breaks, which usually means authentication, payments, and any data a user cannot recreate by hand. Take control of navigation only at the point where the generated routing will not do something you need.
What to leave alone. The helper directory, until it is genuinely in your way. It is ugly and it works. Rewriting it in the first month is the most common self-inflicted wound in a takeover, because it touches every screen, risks every screen, and delivers nothing a user can see. Naming too. Rename things as you pass through files for other reasons, not as a project.
The skill here is restraint, and it is the hardest thing to sell. A good first month on a takeover is measured by how little you changed while continuing to ship. If an engineer's first proposal is to restructure the generated code, that is worth a longer conversation before anyone agrees to it.
Do You Have to Rewrite the App?
Almost never, and the person telling you otherwise should be asked to name which part specifically.
There are three honest cases and they are easy to tell apart once someone lays them out.
A takeover. This is most apps. Export, own the release pipeline, add tests, refactor at the edges, keep shipping. Nothing user-visible changes and no release is skipped. The app your customers have on their phones after the takeover is the same app they had before it, which is exactly the outcome you want and a strangely difficult one to get excited about.
A targeted replacement. One layer is genuinely wrong and gets replaced while everything around it stays. Usually the backend. Sometimes one screen doing something the builder made awkward, which then gets rewritten properly in Dart while the other thirty-nine screens are left exactly as they are.
A genuine rewrite. Rare, and it has a specific signature worth learning. The signature is a product signature, not a code signature. The product changed direction, and what you are holding is a working implementation of the thing you no longer sell. Nobody should rewrite a working app because its code is untidy. Plenty of people have been sold exactly that.
| Case | What triggers it | What changes | What your users see |
|---|---|---|---|
| Takeover | The builder stopped being enough for one feature | Release pipeline, tests, edges of the codebase | Nothing |
| Targeted replacement | One layer is genuinely wrong, usually the data layer | That layer, and nothing around it | Nothing, if it is sequenced properly |
| Genuine rewrite | The product changed direction, not the code quality | Everything, because the thing being built is different | A different product |
On being quoted a rebuild. The fear is reasonable and you should trust it. Rebuilds are simpler to scope, simpler to price and longer to run, which means an agency has structural reasons to prefer one whether or not your app needs it. There is a question that sorts this out quickly. Ask them to name three files in your export that make a rewrite necessary. A team that has actually read the code will answer in specifics, and their answer will be about your data layer or a particular screen. A team that has not will answer about generated code in general.
We will give you that answer for free and in writing, including when the answer is that you do not need us. It is a better outcome for us to be the team that told you the truth than the team that billed you for a quarter you did not need.
Who Should Stay in FlutterFlow?
More teams than the internet suggests. If you are in one of the four groups below, exporting now costs you speed and buys you nothing.
You are before product-market fit and the product changes weekly. The builder is faster than an engineering team at throwing screens away, and throwing screens away is your actual job right now. That is the entire point of the tool and you are using it correctly.
It is an internal tool. Known user count, no store presence, no growth curve to defend. The reasons to own the code are mostly reasons about scale and hiring, and neither applies.
Your fastest shipper is not an engineer. This is the big one and it is nearly always left out. If a designer or a founder is shipping screens in the builder every week, exporting removes your fastest shipper. Whatever you gain in engineering control, you lose in that person's throughput, and in a small team that trade is usually bad.
Your blocker is a feature FlutterFlow will plausibly ship. The product moves. Waiting one quarter is sometimes strictly better than owning a codebase for the rest of the product's life.
What to do instead. Push the boundary with custom actions before you consider exporting. It stays inside the builder, it is reversible, it costs you an afternoon, and within a fortnight you will know whether the wall you hit is real or whether you had simply not found the seam yet. A surprising number of exports we are asked about were triggered by a limit that a custom action would have removed.
If that describes you, close this page and get back to shipping. There is nothing here to buy today.
Do You Have to Leave Firebase?
No. And if you decide you want to, not in the same quarter as the takeover.
The generated backend layer is shaped by whichever data source you connected. Schema classes, queries and the actions that call them are all written against it. Your screens never talk to the backend directly, they talk to that layer. Which is good news for the size of the change and bad news for anyone who assumed it would be small: swapping the backend is a data-layer rewrite, even though not one screen changes visually.
If you are weighing the destination rather than the timing, our comparison of Supabase and Firebase covers the trade properly, including the cases where staying put is the right call.
The reason to separate the two changes is diagnostic, not technical. Change how the app is built and where its data lives in the same weeks and every incident afterwards becomes an argument about which change caused it. You lose the one thing that makes a migration safe, which is the ability to attribute a regression to a cause. Sequence it: take the app over, ship one boring release from the new pipeline, let it sit for a fortnight, then look at the backend again with real data in front of you.
A fair number of teams stop wanting the backend change at that point. What they were escaping turns out to have been a query pattern or a data model rather than a vendor, and that is a much cheaper thing to fix.
What Order Should the Takeover Happen In?
Boring first, interesting last. Here is the order we use, and the reasoning matters more than the list.
1. Freeze the builder. Agree a date after which nothing new gets built visually. Not because the builder is bad, but because two sources of truth is the actual danger and every week you run both doubles the reconciliation later.
2. Export, and commit it untouched. Your first commit is the generated app exactly as it came out, with nothing tidied. You get one chance at a clean baseline and you will want it in three months when you need to know whether something was always like that.
3. Get it building in CI. Both platforms, release mode, signed, no code changes. This is where you find out which parts of the build were being done for you.
4. Ship one release that changes nothing. Same app, new pipeline, out to real users. This is the step teams skip and it is the one that de-risks everything after it, because it proves the whole chain end to end while the app is still known-good.
5. Add tests around what costs money when it breaks. Authentication, payments, and anything a user cannot recreate by hand. Not coverage targets. Three or four tests that would have caught the incidents you are most afraid of.
6. Put an interface in front of the data layer. Now the backend question is open whenever you want it, and closed until then.
7. Now build the feature you exported for. The one that started all this. By this point it is an ordinary piece of Flutter work in a codebase your team controls, which is the whole objective.
Steps one to four contain no interesting engineering whatsoever, and they hold nearly all of the risk. If someone's plan for your takeover opens at step seven, that is worth noticing before you sign anything.
We have run this order on Flutter codebases carrying real load, including a fleet product tracking more than 30,000 vehicles and an exam platform absorbing over 10 million requests a minute at peak. The order does not change with size. What changes is how expensive it is to get it wrong.
If you want a second opinion on your own export before you commit to a direction, our Flutter team will read it and tell you what is actually there.









