Flutter in Production: Beyond the Tutorial
Flutter has matured significantly since its 1.0 release, and in 2025 it is a genuinely production-ready choice for cross-platform mobile development. I have shipped Flutter applications to the App Store and Google Play for clients ranging from small startups to mid-sized enterprises, and I want to share the architectural decisions and tooling choices that have made the biggest difference between a maintainable long-term codebase and one that becomes a liability six months after launch.
Project Architecture: Feature-First Folder Structure
The default Flutter project structure is fine for tutorials and breaks down immediately at scale. I use a feature-first architecture where all code related to a feature lives together: its screens, widgets, state management, repository, and models.
lib/
features/
auth/
data/ # Repository implementations, API clients
domain/ # Entities, repository interfaces, use cases
presentation/ # Screens, widgets, state notifiers
dashboard/
orders/
core/
network/ # HTTP client, interceptors
storage/ # Local storage abstraction
theme/ # App theme, text styles
shared/
widgets/ # Reusable widgets used across features
This structure scales because adding a new feature means adding a new folder. Deleting a feature means deleting a folder. Cross-feature dependencies are explicit imports rather than tangled shared state.
State Management: Riverpod vs Bloc
This is the question I get asked most often. My answer depends on the team and the complexity of the application. Riverpod is my default choice for new projects. It is compile-safe, requires less boilerplate than Bloc, and the AsyncNotifier pattern handles loading, error, and data states elegantly.
Bloc shines when your team has strong Bloc experience or when you need very explicit event-to-state traceability for audit purposes — financial applications, for example. The event-driven architecture makes every state transition intentional and testable in isolation.
// Riverpod AsyncNotifier example
@riverpod
class OrdersNotifier extends _$OrdersNotifier {
@override
Future> build() async {
return ref.read(orderRepositoryProvider).getOrders();
}
Future refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() =>
ref.read(orderRepositoryProvider).getOrders()
);
}
}
Navigation with GoRouter
Navigator 2.0 is powerful and verbose. GoRouter wraps it in a declarative, URL-based API that handles deep linking, nested navigation, and redirect logic cleanly. Define your route tree once, use type-safe go() calls everywhere, and configure redirect guards for authentication.
final router = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isLoggedIn = ref.read(authProvider).isLoggedIn;
if (!isLoggedIn && state.matchedLocation != '/login') return '/login';
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const DashboardScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(
path: '/orders/:id',
builder: (_, state) => OrderDetailScreen(id: state.pathParameters['id']!),
),
],
);
API Integration Patterns
I use Dio as the HTTP client with an interceptor layer for authentication, logging, and error normalization. Every API response maps to a typed model using Freezed for immutable data classes and json_serializable for JSON parsing. The repository pattern abstracts the data source from the domain layer, making it trivial to swap the implementation or add caching.
Local Storage and Push Notifications
For structured local data I use Hive or Isar — both are significantly faster than shared_preferences for anything beyond simple key-value pairs. For push notifications, Firebase Cloud Messaging handles both iOS and Android with a unified API. Handle foreground, background, and terminated app states explicitly — each requires different code and is worth testing on physical devices before release.
CI/CD with Fastlane
Fastlane automates the entire release pipeline. I configure separate lanes for development, staging, and production. The production lane increments the build number, runs Flutter tests, builds the release artifact, uploads to TestFlight and the Google Play internal track, and sends a Slack notification on completion. Running this from GitHub Actions on every merge to main means releases go out consistently without manual intervention.
# fastlane/Fastfile
lane :release_ios do
increment_build_number
flutter_build(platform: 'ios', release: true)
upload_to_testflight(skip_waiting_for_build_processing: true)
end
App Store Submission Tips
Apple's review process catches issues that testing misses. The most common rejection reasons I have encountered: missing privacy manifest declarations for third-party SDKs (required since 2024), missing usage description strings for any permission your app requests, and screenshots that do not match the current app UI. Automate screenshot generation with Fastlane's snapshot tool so they are always current. Address App Transport Security settings explicitly in your Info.plist rather than using the blanket allow-all exception.
Flutter's write-once, deploy-everywhere promise has real limitations — platform-specific UI conventions, native plugin compatibility, and review process differences between stores all require platform-specific attention. But the productivity gain from sharing business logic, state management, and most of the UI across iOS and Android is substantial, and for most application categories it is the right technical choice in 2025.