Native over cross-platform: two apps, one contract
Anyone planning an app for iPhone and Android will sooner or later hear the question: why not write it once and ship it to both platforms? Frameworks such as React Native or Flutter promise exactly that. We have worked with React Native and Expo ourselves, including on an early prototype of our travel expense app ContoTrip. For our own products, however, we chose a different path: each platform is built natively, with no shared code.
What “native” means to us
On iOS we write the app in Swift with SwiftUI, data lives in SwiftData, and syncing runs through CloudKit. On Android, the path is Kotlin with Jetpack Compose. The two implementations stand on their own. There is no shared business logic in a third language, no bridge and no abstraction layer that flattens both worlds into one.
At first glance that sounds like twice the work. In practice it gives us three things in return:
- Platform-appropriate behaviour. Navigation, the share sheet, widgets, shortcuts and system dialogs work the way people expect on their own device.
- Direct access to new system features. When Apple or Google ship a new API, we do not have to wait for a framework update.
- Fewer dependencies. A native project depends on the platform vendor's toolchain – not additionally on a third party and its plugin ecosystem.
To be clear: in our dual-native projects the iOS side is in progress and the Android side is planned. That is exactly why we are defining the common ground for both platforms now.
The only point of contact: the exchange format
Two apps that share no code still need a common language. People send each other entries via WhatsApp, email or AirDrop, and an Android app should later be able to read and write the very same files as the iOS app. We treat this exchange format as a contract. Anything that has been sent once must still be importable a year from now.
The terms of the contract
- A versioned envelope. Every file carries a structure of version, type and payload, such as
{ "v": 1, "type": "…", "payload": { … } }. An importer can recognise older versions and handle them deliberately. - Dates in ISO 8601. Unless told otherwise, Swift encodes dates as a floating-point number of seconds since Apple's 2001 reference date. Outside the Apple world, that is useless.
- Stable English enum values. A priority is stored as
"high", never as “Hoch” or “Yüksek”. Translated labels belong in the interface, not in the data. - Amounts as integer cents. Floating-point numbers and locale-specific formatting lead to rounding errors and parsing trouble.
- A MIME type next to the UTI. iOS identifies file types through Uniform Type Identifiers. If the declaration lacks a MIME type, iOS sends the file as
application/octet-stream, and Android cannot tell what it is.
Recognising a file by its content
Mail clients and messengers rename attachments or attach the wrong type. Our importer therefore does not trust the file extension. It accepts JSON and checks the type field inside the envelope. On top of that, the app produces a readable plain-text version as a fallback for recipients who do not have the app installed.
What is deliberately left out
Database IDs and device-local state stay on the device. The sender's ID means nothing to the recipient. References, for example to a category, are matched by name during import. And an import never happens silently: the recipient sees an editable preview and confirms before anything is saved.
When cross-platform is still the right call
Native development is not a matter of faith. Some projects are better served by a shared codebase:
- a prototype that needs to be tested on both platforms quickly,
- an app that mostly displays forms and lists from an existing API,
- a team that is already deeply invested in React and TypeScript and wants to build on that knowledge.
In those cases we recommend React Native with Expo – and we are upfront about its limits. Native modules such as a document scanner, for instance, do not run in Expo Go, only in a custom development build.
Our takeaway
For products that are meant to last, reach deep into the operating system and feel at home on every platform, we build natively. We offset the extra effort with a clear architecture and a precisely defined exchange format. That format is the real link between the apps. Defining it early spares you the hardest migration of all later on: migrating data that already sits on your users' devices.
Planning an app and not sure which approach fits? We are happy to talk it through with you before the first line of code is written.