Flutter for Cross-Platform Apps: Our Honest Take After 10+ Projects
We have been building with Flutter since 2021. Over that time, we have shipped more than ten cross-platform applications — some for our own products, some for clients. We have dealt with platform-specific bugs, plugin ecosystem gaps, and the occasional “this would have been easier in native” moment.
After all of that, Flutter is still our default choice for mobile development. But not blindly. This post is our honest assessment: what works, what does not, and the specific situations where we tell clients to go native instead.
What Works: The Genuinely Good Parts
Development speed is real. This is the headline benefit, and it is not marketing hype. Building a single codebase that runs on iOS and Android genuinely cuts development time by 40-60% compared to maintaining two native codebases. For a studio our size — four engineers — this means the difference between shipping a mobile app and not having the capacity to build one at all.
Hot reload is a major contributor. Making a UI change and seeing it reflected on a running device in under a second fundamentally changes how you build interfaces. You iterate faster, try more variations, and catch visual issues earlier. Native development has improved (SwiftUI previews, Jetpack Compose live edit), but neither matches Flutter’s hot reload speed and reliability.
The widget system is well-designed. Everything in Flutter is a widget, and the composition model is intuitive once you internalize it. Building complex layouts from small, reusable pieces feels natural.
class BookingCard extends StatelessWidget {
final Booking booking;
final VoidCallback onTap;
const BookingCard({
super.key,
required this.booking,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
booking.serviceName,
style: Theme.of(context).textTheme.titleMedium,
),
BookingStatusBadge(status: booking.status),
],
),
const SizedBox(height: 8),
Text(
booking.formattedDateTime,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
booking.providerName,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
}
This is a real component from MindHyv’s mobile app. The booking card renders identically on iOS and Android, adapts to dark mode via the theme system, and composes smaller widgets (BookingStatusBadge) that are reused across the app.

Dart is a better language than people expect. Developers coming from TypeScript or Kotlin pick up Dart quickly. Null safety, pattern matching, sealed classes, and the upcoming macro system make it a productive language. It is not as elegant as Kotlin or as flexible as TypeScript, but it is solid and well-tooled.
The Supabase Flutter SDK is excellent. Since Supabase is our default backend (see why we use Supabase), the quality of the Flutter SDK matters a lot to us. It supports auth, real-time subscriptions, storage, and edge function invocation with a clean API:
// Initialize Supabase
await Supabase.initialize(
url: dotenv.env['SUPABASE_URL']!,
anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
);
final supabase = Supabase.instance.client;
// Auth with Google
await supabase.auth.signInWithOAuth(
OAuthProvider.google,
redirectTo: 'com.mindhyv.app://callback',
);
// Query with RLS applied automatically
final bookings = await supabase
.from('bookings')
.select('*, services(*), providers(*)')
.eq('status', 'confirmed')
.order('scheduled_at');
// Real-time subscription
supabase
.channel('bookings')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'bookings',
callback: (payload) {
// Handle new booking
},
)
.subscribe();
What Does Not Work: The Pain Points
Platform-specific behavior is unavoidable. Flutter’s promise is “write once, run anywhere,” but the reality is “write once, test and adjust everywhere.” iOS and Android have different navigation patterns (back swipe vs. back button), different permission models, different notification systems, and different keyboard behaviors. You will write platform-specific code.
// Platform-specific behavior you will inevitably write
import 'dart:io' show Platform;
Widget buildNavigation() {
if (Platform.isIOS) {
return CupertinoTabBar(
items: _buildTabItems(),
);
}
return NavigationBar(
destinations: _buildDestinations(),
);
}
We budget 15-20% of development time for platform-specific adjustments on every project. That is honest. Anyone who tells you Flutter eliminates platform differences entirely is selling something.
The plugin ecosystem is hit or miss. Core plugins maintained by the Flutter team (camera, local_auth, shared_preferences) are solid. Third-party plugins range from excellent to abandoned. Before committing to any plugin, we check three things: when was the last commit, does it support the latest Flutter version, and does it have open issues that would block us.
We have been burned by plugins that worked fine on Android but crashed on iOS, or that stopped working after a Flutter version upgrade. Our rule: if a plugin is critical to the app’s core functionality, we evaluate whether we can build the native bridge ourselves as a fallback.
Deep linking and navigation are complex. Flutter’s navigation system has been through multiple iterations (Navigator 1.0, Navigator 2.0, go_router, auto_route). None of them are as straightforward as web routing. Deep linking — handling URLs that open specific screens in the app — requires configuration on both platforms plus the Flutter routing setup, and it is easy to get wrong.
// go_router setup - the least painful option we have found
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/bookings/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return BookingDetailScreen(bookingId: id);
},
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
routes: [
GoRoute(
path: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
],
);
Build sizes are larger than native. A minimal Flutter app starts at around 15-20 MB on Android and 30-40 MB on iOS. A minimal native app starts at 5-10 MB. For most apps, this does not matter — users do not care about a 20 MB difference. But for markets with limited bandwidth or storage (which we encounter working with clients in Latin America and Southeast Asia), it is a real consideration.
Animations can feel slightly off. Flutter’s animation system is powerful, but the default Material animations do not always match the platform’s native feel. iOS users in particular notice when scroll physics, page transitions, or haptic feedback do not feel like a native iOS app. You can customize all of this, but it takes effort.

State Management: What We Actually Use
The Flutter community has a reputation for endless state management debates. After trying most of the options (Provider, Riverpod, BLoC, GetX, MobX), we have settled on Riverpod for all new projects. Here is why:
- Compile-time safety (no runtime ProviderNotFoundExceptions)
- Works outside the widget tree (in services, repositories)
- Built-in caching and auto-dispose
- Clean separation between UI and business logic
// A typical Riverpod provider in our projects
@riverpod
class BookingsNotifier extends _$BookingsNotifier {
@override
FutureOr<List<Booking>> build() async {
final supabase = ref.watch(supabaseProvider);
final response = await supabase
.from('bookings')
.select('*, services(*)')
.order('scheduled_at');
return response.map((json) => Booking.fromJson(json)).toList();
}
Future<void> cancelBooking(String bookingId) async {
final supabase = ref.read(supabaseProvider);
await supabase
.from('bookings')
.update({'status': 'cancelled'})
.eq('id', bookingId);
ref.invalidateSelf();
}
}
// In the UI
class BookingsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final bookingsAsync = ref.watch(bookingsNotifierProvider);
return bookingsAsync.when(
data: (bookings) => BookingsList(bookings: bookings),
loading: () => const BookingsShimmer(),
error: (error, stack) => ErrorDisplay(message: error.toString()),
);
}
}
This pattern — Riverpod providers wrapping Supabase queries — is the backbone of our Flutter applications. It gives us reactive UI updates, clean error handling, and automatic loading states.
When We Recommend Native Instead
There are specific situations where we tell clients to skip Flutter and go native:
Hardware-intensive applications. If the app’s core value depends on camera processing, AR, Bluetooth Low Energy, or real-time sensor data, the abstraction layer adds latency and complexity. For these apps, native gives you better performance and more reliable access to platform APIs.
Apps that must look and feel 100% platform-native. Some apps — especially enterprise or business tools — need to feel indistinguishable from a native app. Flutter can get close, but “close” is not the same as native. If your users are the kind who notice subtle animation timing differences, go native.
Single-platform launches. If you are only launching on iOS (or only on Android) for the foreseeable future, the cross-platform benefit disappears and you are left with the tradeoffs (larger bundle, plugin limitations, non-native feel) without the primary benefit.
Apps with heavy platform-specific integrations. If your app relies heavily on HealthKit, Android Auto, CarPlay, Siri Shortcuts, or other deep platform integrations, you will spend more time writing native bridges than you would just writing native code.
Real Projects: Where Flutter Worked (and Where It Struggled)
On MindHyv, Flutter was the right choice. The app has a social feed, booking system, invoicing, and a digital storefront — all standard UI patterns that Flutter handles well. The single codebase saved us months, and the app performs well on both platforms.
On JustTheRip, the digital pack-opening platform, Flutter handled the core experience well but we had to invest extra time in the card animations. The pack-opening animation — tearing open a pack, revealing cards with physics-based movement — required custom render objects and careful optimization to hit 60fps consistently on mid-range Android devices.
For VincelIO, the influencer marketplace connecting brands and creators in Latin America, Flutter let us ship on both platforms simultaneously, which was critical for reaching both Android-heavy markets in Mexico and iOS users in urban Brazil.

Our Flutter Project Template
Every Flutter project we start comes from an internal template that includes:
- Riverpod for state management
- go_router for navigation
- Supabase SDK configured with auth flows
- A theme system with light/dark mode
- Common widgets (loading states, error displays, empty states)
- CI/CD with GitHub Actions (build for both platforms)
- Fastlane for automated releases
This template cuts our project setup from days to hours. The first meaningful feature is usually in progress by end of day one.
The Bottom Line
Flutter is a productivity multiplier for teams building standard mobile applications that need to ship on both platforms. It is not a silver bullet. You will deal with platform quirks, plugin limitations, and the occasional “this would be easier in native” moment. But for 80% of the apps we build, those tradeoffs are worth the 40-60% reduction in development time.
The framework has matured significantly since we started using it. The type system is stronger, the plugin ecosystem is more stable, and the tooling (DevTools, Impeller renderer, custom lint rules) has caught up to native development in many respects.
If you are evaluating Flutter for a mobile project, the most important question is not “is Flutter good?” — it is “is Flutter right for this specific app?” We are happy to help you answer that. Reach out at [email protected].