Cross Platform App Development

Cross-platform app development gets you iOS and Android from one codebase – the right economics for most business apps, where a shared build means one team, one set of features, and roughly half the cost of two native apps. Software Mile builds cross-platform apps in React Native and Flutter.

When One Codebase Is the Smart Choice

Cross-platform fits the large majority of business apps: forms, dashboards, self-service, field tools, and internal utilities where the app is about function, not squeezing the last frame of animation from the GPU. You reach both platforms faster and maintain one codebase instead of two.

And When It Is Not

We are honest about the edges: apps that push graphics hard, depend on cutting-edge device APIs, or where every millisecond counts are better native. Part of scoping is telling you which side of that line your app sits on – before you commit.

  • React Native and Flutter builds targeting iOS and Android together
  • Native modules where a specific feature needs to drop to the metal
  • Backend integration, offline sync, and store submission for both platforms

Describe your app and target platforms – we will recommend cross-platform or native honestly, and scope the build.

React Native and Flutter Are Both Credible Choices

Both produce production apps for iOS and Android from a single codebase, and for a lot of projects either would work. The deciding factors are usually organizational.

React Native uses JavaScript and TypeScript, so a team that already has web developers can share people, tooling, and sometimes code with a React front end from our web development work or your own. Flutter uses Dart and draws its own widgets instead of mapping to platform controls, which gives tighter control over custom interfaces and consistency across both platforms. Ask who will maintain the app in three years and which of the two they are likely to know already.

What Does One Codebase Not Cover?

The shared code is the app’s features. Almost everything around it stays doubled, and that is where the single-codebase advantage tapers. Knowing this in advance keeps the second half of the project from feeling like a bait and switch.

  • Two developer accounts, two store listings, and two review processes with different rules
  • Two push notification setups, because Apple and Google run separate delivery services
  • Platform-specific permissions, privacy declarations, and app signing
  • Native build tooling on both sides, including a Mac somewhere in the pipeline for iOS builds
  • Someone on the team who can read a native crash log when the problem is below the framework

When Does the Third-Party SDK You Need Simply Not Exist?

This is the failure mode that catches people after the decision is made. Payment terminals, barcode and RFID hardware, specialist medical devices, device management SDKs, and some enterprise authentication libraries ship native only. A cross-platform app can still use them, but somebody has to write and maintain a bridge on each platform.

Check before you commit. List every external system and piece of hardware the app touches, then confirm whether a maintained plugin exists, whether it is a community project or vendor supported, and what happens when the vendor updates their native SDK. A handful of unsupported hardware integrations can erase the reason you chose a shared codebase in the first place.

Are Kotlin Multiplatform or .NET MAUI Worth a Look?

In specific situations, yes. Kotlin Multiplatform shares business logic, networking, and data handling while each platform keeps its own native interface. That suits organizations who already employ native iOS and Android developers and do not want to give up native UI, though it shares less than React Native or Flutter. .NET MAUI is worth considering if your backend and internal tooling are already .NET and your developers write C# every day.

The trade-off with both is the hiring pool. Ask who maintains this if the original developer leaves, and how hard that person will be to find.

Can You Move an Existing Native App to a Shared Codebase?

Yes, and it does not have to happen all at once. Both React Native and Flutter can be embedded inside an existing native app, so new screens use the shared code while the established ones stay where they are. That path suits a long-lived app where a full rewrite would be reckless.

The case for doing it at all is usually organizational. If one small team is maintaining two native codebases that have quietly diverged, and every feature ships twice with different bugs in each, consolidation is worth serious consideration. If both apps are healthy and the teams are separate, migrating for tidiness is hard to justify.

The Order the Work Happens In, and What It Waits On

The sequence matters as much as the list. The integration audit comes first, then the backend contract, then platform accounts and signing, then build. Each stage gates the next: screens built against an API that still has to change get rebuilt, and a finished binary sits idle if the accounts it will be submitted from do not exist yet or the enrollment is still being verified.

The backend contract deserves separate attention because a mobile client is not a web front end. An installed app stays on whatever version the user last accepted, so from the first release the API has to tolerate old clients: additive changes rather than renamed or removed fields, a version identifier the server can read, and errors returned as structured data the app can act on rather than an HTML error page. Token based authentication with a refresh path fits the device lifecycle better than a cookie session, because the app gets closed and reopened days later on a network that has changed underneath it.

One set of decisions belongs to you rather than to the framework: how closely the app follows each platform's own conventions. Android's system back gesture, iOS safe areas around the notch and home indicator, and operating system font scaling all affect layout, and a design that ignores them looks correct in the mockup and wrong on hardware.

  • A backend the app can reach, plus a staging environment that is not production
  • A decision on authentication: your existing identity provider, a third party, or something built for the app
  • Developer accounts registered to your legal entity, with development access granted as a role, so ownership is never something that has to be transferred later
  • Test devices covering the oldest operating system versions and the smallest screen you intend to support, not only current hardware
  • A named person who can approve listing content and answer data collection questions truthfully

What Actually Requires an Installed App

Start from capabilities rather than screens. Sustained camera use for barcode or document capture, Bluetooth Low Energy peripherals, background location, notifications on a locked screen, storage that keeps working through a full shift with no signal, and an icon someone taps without typing an address: those are what an installed binary gives you that a browser tab does not. If any of them are on the list, the only remaining question is cross-platform or native, and a shared codebase answers it for both stores at once.

The narrow case that runs the other way is worth naming during scoping: a single signed-in lookup screen for staff who work at company machines and never take the task into the field. None of those capabilities apply there, and shipping it through the stores adds a review queue and an update lag it does not need. That is a scoping conversation to have before the platform choice, not a substitute for it.

Offline Sync and Background Work Are Where the Shared Layer Gets Thin

Offline is not a framework setting, it is a data design decision. You need a local database, usually SQLite through a binding on either stack, a queue of pending writes, and a rule for what happens when two people edit the same record while both are disconnected. Last write wins is a choice rather than a default, and it is the wrong choice for anything where the losing edit contains work somebody actually did. Settle the conflict rule per entity before the schema is written, because retrofitting it means rewriting the sync layer.

Background execution is where both operating systems disagree with you. iOS suspends an app shortly after it leaves the foreground and grants background time on its own schedule; Android applies Doze and app standby buckets, and battery managers on some manufacturers' devices are more aggressive than stock Android. A sync described as running every fifteen minutes really runs when the operating system allows it, so the app has to be correct when it wakes after six hours instead, and the interface has to show when data last synced successfully rather than implying it is current.

Both delivery services are best effort, and neither gives you a per message receipt saying a device displayed anything. A notification can be dropped after a device has been offline too long, collapsed with a later one carrying the same collapse key, or withheld entirely while the app is in a force stopped state. Treat push as a signal telling the app to go fetch, not as the transport for the data itself, and give the user a path to the same information without the notification.

Shipping the Second Release: Review and Staged Rollout

The first submission is the slow one, because the account, the certificates, the privacy declarations, and the listing all get checked at once. After that the mechanics are routine, but the two platforms stay out of step: a build can be live on one while the other is still in review, so any change that requires both apps to be on the same version at the same moment needs a server side switch rather than a coordinated release date. Both platforms let you release to a percentage of users and pause a rollout in progress, which is what you want when a crash surfaces in the first few hours.

Rejections are mostly predictable and mostly not about code: an account required with no way for the reviewer to sign in, permissions requested with no visible feature behind them, a privacy answer that does not match what the app actually transmits, or a required sign-in option missing alongside third party login. Supply reviewer credentials against a working test account, and keep the description of the data flow accurate as the app changes, because the declaration is checked against behavior rather than intent.

Version adoption is the part you cannot fix from your side. An installed app stays on the build the user last accepted, so a fix does not reach everyone the day it ships. Both stores offer a way to require an update before the user continues, but the practical consequence is that the API has to keep serving older clients for as long as those clients are in the field, which is the same reason the version identifier belongs in the first release.

The App Still Needs Work in a Year With No New Features

Google Play requires new apps and updates to target a recent Android API level, and the required level moves every year. Apple periodically requires new submissions to be built with a current Xcode and SDK. Neither is negotiable, and both apply even when your feature list has not changed, so an app that is finished still needs a scheduled build to remain updatable.

Certificates and provisioning profiles expire on their own timetable and will block a build the day they lapse, usually the day someone needs an urgent fix out. The framework has a floor of its own: React Native and Flutter each drop support for older versions, and once you are several releases behind, the upgrade stops being a version bump and becomes a dependency untangling exercise where native modules break in ways the release notes do not cover.

  • Plan a maintenance release each year even in a quarter with no feature work, because the deadline comes from the platforms rather than from your roadmap
  • Keep a build environment and a committed dependency lockfile that can still reproduce the last shipped release, so an urgent fix does not begin with reconstructing the toolchain
  • Re-test on each major operating system release, since permission prompts, background limits, and default behaviors change with them

Frequently Asked Questions

Is a cross-platform app the same thing as a website in a wrapper?

No. React Native and Flutter compile to a native binary that runs compiled code on the device and calls platform APIs directly, rather than loading a page inside an embedded browser. A wrapped web view is a third option and behaves differently: it degrades badly with no signal, it inherits browser scrolling and text input quirks, and it reaches hardware only through whatever bridge the wrapper provides. Name which of the three you are buying at the start, because the capability ceiling and the maintenance profile are not the same.

What has to exist on the server side before the app can do anything useful?

An HTTP API the app can reach from outside your network, and a staging copy of it that is not production. If the current system only talks to an internal web front end over a session cookie on a private network, then exposing and securing an API is part of the project rather than a prerequisite you can assume is already met. That work has its own timeline and its own security review, and it usually starts before the first screen is built.

How does app signing work, and what happens if a key is lost?

Every build is signed with a key tied to your store account, and the recovery story differs by platform. With Play App Signing, Google holds the app signing key and you hold an upload key, so a lost upload key can be reset through support; if the app predates that and you hold the app signing key yourself, losing it generally means publishing under a new listing and asking users to install a different app. On iOS the distribution certificate can be revoked and reissued from the account, so the cost of losing it is downtime rather than a permanent loss. Keep both in a secret store the company controls rather than on one developer's laptop.

Where do the iOS builds get signed and run?

On a machine that holds your signing certificates and provisioning profiles, which makes the build pipeline question mostly an access question. It has to be a Mac, either one in your office or a hosted Mac runner from a CI provider, and you have to decide whether the credentials live on that machine, in a shared encrypted repository, or under the store's managed signing. Android has no equivalent hardware constraint, so those builds can run on the same Linux CI as the rest of your stack.

What has to be ready that is not code?

Listing text, screenshots at the sizes each store requires for the device classes you support, an icon, a publicly reachable privacy policy URL, and a support contact. You also need someone inside the company who can state accurately what data the app collects and where it goes, because both stores turn that into a declaration attached to your listing and check it against the app's behavior. These are usually assembled last, and submission cannot proceed without them.

Can one app serve both customers and internal staff?

It can, but decide early whether that means one binary with roles or two separate listings. One binary puts your internal features through public store review and into a public listing, which constrains how they can look and what they can assume. Two listings mean two of everything the shared codebase does not cover, though the code can stay a single project with two build configurations, which are flavors on Android and schemes and targets on iOS. Both platforms also have managed distribution channels for internal-only apps, and those change who can install and how updates arrive.

How do you diagnose a crash the framework does not explain?

Wire up crash reporting before the first release and make the build pipeline upload symbol files on every build: dSYMs for iOS, the R8 or ProGuard mapping file for Android, and the debug symbols or source maps for the shared code. Without them a production crash arrives as unreadable memory addresses and there is no way to recover the stack after the fact. When the trace ends in native frames rather than your own code, the cause is almost always a plugin or the platform itself, which changes who has to fix it.