Pub.dev now hosts over 45,000 packages. Most of them are garbage. Some are brilliant. A handful are so
essential that I can't imagine starting a Flutter project without them. After shipping 150+ production apps
at Flutter Studio, I've developed very strong opinions about which packages earn a spot in our
pubspec.yaml and which ones are a liability waiting to happen.
This isn't a "here are 10 popular packages" list. This is the exact stack I install on day one of every new project, why each one earned its place, and the real code patterns we use in production. I'll also tell you which popular packages I actively avoid — because knowing what not to install is just as important.
⚠️ Before You Start
Every package version in this article reflects what we're running in production as of March 2026. Flutter and its ecosystem move fast — always check pub.dev for the latest compatible versions before adding to your project.
Why I'm Opinionated About Package Selection
Early in my career, I added packages like I was shopping at a buffet. Need a shimmer effect? Package. Need a fancy toast notification? Package. Need custom scroll physics? There's gotta be a package for that. By the time I had 45 dependencies in a single project, I discovered the real cost: Flutter upgrades became a nightmare. Half the packages would break on a major version bump, and I'd spend two weeks fixing dependency conflicts instead of shipping features.
Now I have three rules before adding any package:
- Is it actively maintained? I check the last commit date, open issues count, and whether the maintainer responds to bug reports. If the last commit was 6+ months ago, I'm out.
- Could I build this myself in under a day? If yes, I usually do — it's one fewer dependency to manage. If no, the package is earning its keep.
- Does it have tests? I check the package's test coverage. If a state management library doesn't test its own state management, why should I trust it with mine?
These three rules eliminated about 60% of the packages I used to reach for. Here are the 10 that survived the cut, in the order I add them to a new project.
1. Riverpod — State Management That Doesn't Fight You
I've used every state management solution Flutter has to offer — Provider, BLoC, GetX, Riverpod, and even the newer Signals approach. After all of them, Riverpod is the one I keep coming back to. Not because it's trendy, but because it solves the actual problems I hit in production:
- Compile-time safety — no more
ProviderNotFoundExceptionat runtime - No BuildContext dependency — I can access state from anywhere, including background isolates
- Excellent testability —
ProviderContainermakes unit tests trivial to write - Auto-dispose — state cleans itself up when nothing is listening
Here's the pattern I use on every project — a thin service layer that separates business logic from UI:
// providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AsyncValue<User?> build() {
// Watch the auth state stream
final authStream = ref.watch(authRepositoryProvider).authStateChanges;
ref.onDispose(() => authStream.cancel());
return const AsyncValue.loading();
}
Future<void> signIn(String email, String password) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => ref.read(authRepositoryProvider).signIn(email, password),
);
}
Future<void> signOut() async {
await ref.read(authRepositoryProvider).signOut();
state = const AsyncValue.data(null);
}
}The code generation approach with @riverpod annotation eliminates the verbosity of manually
defining providers. If you're migrating from an older Riverpod version, I wrote a step-by-step Riverpod 3 migration guide that covers every
breaking change. And if you're coming from Provider, my complete migration guide walks through the
entire Provider-to-Riverpod transition.
💡 Riverpod vs Signals
Flutter Signals is gaining traction as a lighter alternative for reactive state. I've written about it in depth — it's excellent for simple reactive values but doesn't match Riverpod's dependency injection and lifecycle management for large apps. For greenfield projects with 20+ providers, Riverpod is still my pick.
2. Dio — The HTTP Client for Real Apps
The built-in http package is fine for a tutorial. The moment you need auth token refresh,
request retries, upload progress, or global error handling, you need Dio. I've tried switching to alternatives like chopper
and retrofit, but I always come back to Dio because the interceptor system is unbeatable.
Here's the base HTTP client setup I use on every project:
import 'package:dio/dio.dart';
class ApiClient {
late final Dio _dio;
ApiClient({required String baseUrl, required TokenStore tokenStore}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
// Auth interceptor — automatically attaches token
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await tokenStore.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
// Token expired — refresh and retry
final newToken = await tokenStore.refreshToken();
if (newToken != null) {
error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final retryResponse = await _dio.fetch(error.requestOptions);
return handler.resolve(retryResponse);
}
}
handler.next(error);
},
));
// Logging in debug mode only
assert(() {
_dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
return true;
}());
}
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? params}) =>
_dio.get<T>(path, queryParameters: params);
Future<Response<T>> post<T>(String path, {dynamic data}) =>
_dio.post<T>(path, data: data);
}The token refresh interceptor alone saves me hours on every project. Without it, you'd need to manually handle 401 responses in every single API call. I use this same pattern when connecting Flutter to Firebase Cloud Functions — the interceptor handles Firebase ID token refresh seamlessly.
3. GoRouter — Navigation Without the Pain
Flutter's navigation system before GoRouter was a war crime. Navigator 1.0 was imperative spaghetti, and Navigator 2.0 was so overengineered that the Flutter team themselves admitted it was too complex. GoRouter is the fix — declarative, URL-based routing that works perfectly on mobile, web, and desktop.
Here's how I set up routing with auth guards — the pattern I use on literally every app:
import 'package:go_router/go_router.dart';
final goRouter = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isLoggedIn = authNotifier.value != null;
final isOnLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isOnLogin) return '/login';
if (isLoggedIn && isOnLogin) return '/';
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'profile/:userId',
builder: (context, state) => ProfileScreen(
userId: state.pathParameters['userId']!,
),
),
],
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
],
);The ShellRoute pattern is a game-changer for apps with bottom navigation bars — the scaffold
persists while only the body content swaps. And because GoRouter uses URL paths, deep linking works for free
on both mobile and Flutter Web.
4. Freezed — Kill the Boilerplate
Writing a data class in Dart without Freezed means manually implementing ==, hashCode,
toString, copyWith, and JSON serialization for every single model. On a project
with 40 data models, that's thousands of lines of repetitive, error-prone code. Freezed generates all of it
from a single class definition.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
part 'product.g.dart';
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
required double price,
@Default('') String description,
@Default([]) List<String> imageUrls,
@Default(0) int stockCount,
DateTime? createdAt,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
}
// Usage — immutable updates are trivial:
final updated = product.copyWith(
price: 29.99,
stockCount: product.stockCount - 1,
);The sealed union feature is equally powerful for modeling states that have distinct shapes:
@freezed
sealed class PaymentResult with _$PaymentResult {
const factory PaymentResult.success({required String transactionId}) = PaymentSuccess;
const factory PaymentResult.failed({required String errorMessage}) = PaymentFailed;
const factory PaymentResult.cancelled() = PaymentCancelled;
}
// Exhaustive pattern matching — compiler catches missing cases
Widget buildResult(PaymentResult result) => switch (result) {
PaymentSuccess(:final transactionId) => Text('Paid: $transactionId'),
PaymentFailed(:final errorMessage) => Text('Error: $errorMessage'),
PaymentCancelled() => const Text('Cancelled by user'),
};I use Freezed for every model that crosses a boundary — API responses, navigation arguments, state objects. For e-commerce apps with Stripe, the payment status union type alone prevents an entire class of bugs.
5. Flutter Hooks — React-Style Logic Reuse
If you've ever copied an AnimationController with its initState,
dispose, and TickerProviderStateMixin boilerplate between five different widgets,
you know why Flutter
Hooks exist. Hooks let you extract and reuse stateful logic without the ceremony of
StatefulWidget.
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class SearchScreen extends HookConsumerWidget {
const SearchScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final debounceTimer = useRef<Timer?>(null);
final results = ref.watch(searchResultsProvider);
useEffect(() {
void listener() {
debounceTimer.value?.cancel();
debounceTimer.value = Timer(const Duration(milliseconds: 400), () {
ref.read(searchQueryProvider.notifier).state = searchController.text;
});
}
searchController.addListener(listener);
return () => searchController.removeListener(listener);
}, [searchController]);
return Column(
children: [
TextField(controller: searchController),
Expanded(child: SearchResultsList(results: results)),
],
);
}
}The combination of hooks_riverpod with HookConsumerWidget is the cleanest widget
pattern I've found in Flutter. It handles text controllers, animation controllers, focus nodes, and scroll
controllers without any of the StatefulWidget lifecycle noise.
6. Drift — Local Database Done Right
I used to recommend Hive for local storage. Not anymore. Drift (formerly Moor) has become the clear winner for any app that needs local data persistence beyond simple key-value pairs. If you're building offline-first Flutter apps, Drift is the only serious option.
Why the switch from Hive? Three reasons: Drift gives you type-safe SQL queries, reactive streams that automatically update your UI when data changes, and proper schema migrations — something Hive simply doesn't support. When I needed to add a column to a table in a Hive-based app, I had to write manual migration code that was fragile and untestable. With Drift, it's a one-liner.
import 'package:drift/drift.dart';
// Define your table
class Products extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 100)();
RealColumn get price => real()();
BoolColumn get inStock => boolean().withDefault(const Constant(true))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
// Type-safe queries with reactive streams
Stream<List<Product>> watchInStockProducts() {
return (select(products)
..where((p) => p.inStock.equals(true))
..orderBy([(p) => OrderingTerm.desc(p.createdAt)]))
.watch();
}
// Schema migration — clean and testable
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: stepByStep(
from1To2: (m, schema) async {
await m.addColumn(schema.products, schema.products.inStock);
},
),
);💡 Drift vs Hive vs Isar
Drift — Best for relational data, complex queries, and offline-first apps. Works on all
platforms including web (via IndexedDB).
Hive — Still good for simple key-value storage like user preferences and settings.
Don't use it for relational data.
Isar — Promising NoSQL alternative, but its development pace has been inconsistent. I'd
wait for it to stabilize before using in production.
7. Cached Network Image — Image Loading That Actually Works
Loading images from the internet sounds simple until you deal with slow connections, broken URLs, placeholder states, cache invalidation, and memory pressure from loading 200 thumbnails in a grid. Cached Network Image handles all of this with a single widget.
CachedNetworkImage(
imageUrl: product.imageUrl,
width: 120,
height: 120,
fit: BoxFit.cover,
placeholder: (context, url) => const ShimmerPlaceholder(),
errorWidget: (context, url, error) => const Icon(Icons.broken_image),
memCacheWidth: 240, // Resize in memory — saves RAM
cacheManager: CacheManager(
Config(
'product_images',
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 200,
),
),
)The memCacheWidth parameter is the hidden gem most developers miss. If you're displaying a 120px
thumbnail but the source image is 2000px wide, Flutter loads the full 2000px image into memory. Setting
memCacheWidth to 2x the display size (240px) keeps memory usage predictable. On a performance-optimized app, this single parameter can reduce
memory usage by 60% on image-heavy screens.
8. Flutter Animate — Microinteractions in One Line
Good UI is about the details. A subtle fade-in when content loads. A bounce when a button is tapped. A slide
transition between list items. Without Flutter Animate, each of these requires an AnimationController, a
TickerProviderStateMixin, and 30 lines of boilerplate. With it, they're one-liners. I covered
animation patterns for better UX in a separate
deep-dive — Flutter Animate is the backbone of that approach.
// Fade in + slide up on appear
Text('Welcome back!')
.animate()
.fadeIn(duration: 600.ms, curve: Curves.easeOut)
.slideY(begin: 0.1, end: 0, duration: 600.ms)
// Staggered list animation
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text(items[index].name))
.animate()
.fadeIn(delay: (50 * index).ms)
.slideX(begin: 0.1, delay: (50 * index).ms),
)
// Shimmer loading effect
Container(width: 200, height: 20, color: Colors.grey.shade300)
.animate(onPlay: (c) => c.repeat())
.shimmer(duration: 1200.ms)The chainable API makes it easy to compose complex animations from simple building blocks. And unlike manual
AnimationController code, Flutter Animate handles disposal automatically — no more
animation-after-dispose crashes.
9. Very Good Analysis — Lint Rules That Scale
Every project starts disciplined and slowly drifts toward chaos. Very Good Analysis from Very Good Ventures prevents that drift with 100+ strict but sensible lint rules. It catches common mistakes, enforces immutability where possible, and makes code reviews faster because the linter already caught the formatting and style issues.
# analysis_options.yaml
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-raw-types: true
strict-inference: true
exclude:
- '**/*.g.dart'
- '**/*.freezed.dart'
linter:
rules:
# I override these two — VGA defaults are too strict for my taste
lines_longer_than_80_chars: false
flutter_style_todos: falseThe three strict analyzer flags are the important part. strict-casts disables implicit
downcasts, strict-raw-types forces you to specify generic types, and
strict-inference catches cases where the analyzer can't infer a type. Together, they catch
about 80% of the type-related bugs I used to encounter. For team projects, these rules are non-negotiable —
they're the foundation of our security-first development
approach.
10. Connectivity Plus — Offline-Aware Apps
Users don't always have internet. If your app silently fails or shows a spinning loader forever when the connection drops, that's a terrible experience. Connectivity Plus gives you real-time network state changes so you can gracefully handle offline scenarios.
import 'package:connectivity_plus/connectivity_plus.dart';
class NetworkMonitor {
final _connectivity = Connectivity();
late final StreamSubscription _subscription;
final _isOnline = ValueNotifier<bool>(true);
ValueListenable<bool> get isOnline => _isOnline;
void init() {
_subscription = _connectivity.onConnectivityChanged.listen((results) {
_isOnline.value = results.any((r) => r != ConnectivityResult.none);
});
}
void dispose() => _subscription.cancel();
}
// In your widget tree — show an offline banner
ValueListenableBuilder<bool>(
valueListenable: networkMonitor.isOnline,
builder: (context, isOnline, child) => Column(
children: [
if (!isOnline)
const MaterialBanner(
content: Text('You\'re offline. Changes will sync when reconnected.'),
actions: [SizedBox.shrink()],
backgroundColor: Colors.orange,
),
Expanded(child: child!),
],
),
child: const AppContent(),
)I pair Connectivity Plus with Drift's offline-first architecture — the local database handles reads while offline, and a sync queue processes pending writes when connectivity returns. This pattern works for everything from note-taking apps to field inspection tools where internet is intermittent.
⚠️ Connectivity Plus Gotcha
Connectivity Plus tells you if the device has a network interface active — not whether the
internet is actually reachable. A phone connected to a Wi-Fi router with no internet will report
"connected." For true reachability checks, pair it with a lightweight ping to your own server or use the
internet_connection_checker package as a secondary confirmation.
Honorable Mentions
These didn't make the top 10, but they're excellent packages I use frequently on specific project types:
- auto_route — Code-generated routing. Heavier than GoRouter but has better type safety for complex navigation patterns.
- flutter_bloc — Still the go-to for enterprise teams wanting strict unidirectional data flow. I use it on projects where the client insists on BLoC.
- envied —
Compile-time environment variables. Better than
dotenvbecause secrets are obfuscated and type-safe. - url_launcher — Open URLs, emails, phone numbers. First-party package from the Flutter team.
- firebase_core — If you're using Firebase (and many of our projects do), this is the mandatory base package. See our Supabase vs Firebase comparison if you're deciding between backends.
- json_annotation — Works with Freezed for JSON serialization. If
you're not using Freezed,
json_serializable+json_annotationis the next best thing. - flutter_native_splash — Generates native splash screens from a YAML config. Takes 2 minutes and looks professional immediately.
How I Evaluate New Packages
When a new package shows up on Twitter or Reddit and everyone is hyping it, I run it through a quick checklist before even opening the pub.dev page:
- Check the "Likes" and "Pub Points" on pub.dev — Under 100 likes and I'm cautious. Under 50 pub points means it's not following Dart ecosystem best practices.
- Read the changelog, not just the README — A beautiful README means good marketing. A detailed changelog with semantic versioning means good engineering. I want to see the engineering.
- Search the issue tracker for "breaking" — If a package has frequent breaking changes without major version bumps, it's not ready for production.
- Check Flutter version constraints — If it doesn't support the last 2 stable Flutter releases, the maintainer isn't keeping up.
- Look at the dependency tree — A package that pulls in 15 transitive dependencies is a
maintenance liability. Run
dart pub deps --style=treeto see the full picture.
This checklist takes about 5 minutes and has saved me from adopting packages that looked great on day one but caused problems on day 90. I apply the same rigor when evaluating AI integration packages — the space is moving so fast that half the packages are abandoned within 6 months.
🛠️ Our pubspec.yaml Template
We maintain an internal project template at Flutter Studio with all 10 packages pre-configured, along with our analysis_options.yaml, VS Code settings, and CI/CD pipeline. When we start a new project, we run one command and get a production-ready skeleton in 30 seconds. If you're building a Flutter startup, having this kind of template eliminates days of setup time.
Packages I Regret Using — Lessons Learned
It's easy to recommend good packages. It's more useful to warn you about the ones that looked good but caused real problems:
GetX for large projects. GetX is fantastic for prototyping and small apps. But on two projects with 50+ screens, GetX's implicit dependency injection became a debugging nightmare. Services were being created and disposed in unpredictable orders, and the lack of compile-time safety meant bugs only showed up at runtime. I've written about GetX patterns that mitigate some of these issues, but for large apps, I now default to Riverpod.
flutter_screenutil for responsive design. This package uses magic multipliers
(.w, .h, .sp) that look clean but create subtle bugs. A
12.sp font that works on a Pixel 7 might be unreadable on an iPad. The Flutter framework's own
MediaQuery, LayoutBuilder, and Flex widgets handle responsive design
better — no package needed. I'd rather spend 20 minutes writing a responsive breakpoints utility than deal
with screenutil's edge cases.
Overly-magical code generators. I tried a package that auto-generated entire repository layers from annotated interfaces. It worked beautifully until it didn't — when I needed a query that didn't fit the annotation pattern, I had to fight the generator instead of just writing the code. Now I use code generation selectively: Freezed for data classes, Riverpod annotations for providers, and that's mostly it.
Any package without a migration path. I once adopted a navigation package that went through 3 breaking changes in 4 months with no migration guide. I spent more time updating the package than building features. Now my first check is: does this package have a documented migration path between major versions? GoRouter and Riverpod both excel here.
The lesson across all of these: simplicity wins. The best packages do one thing well, have clear APIs, and don't try to be a framework. If a package requires you to structure your entire app around it, that's not a package — that's a commitment. For any Flutter project aiming at peak performance, fewer dependencies means less overhead, faster compile times, and fewer potential points of failure.
📚 Related Articles
Frequently Asked Questions
What are the best Flutter packages in 2026?
The essential packages for production Flutter development in 2026 are Riverpod for state management, Dio for HTTP networking, GoRouter for navigation, Freezed for immutable data classes, Flutter Hooks for reusable stateful logic, Drift for local databases, Cached Network Image for efficient image loading, Flutter Animate for animations, Very Good Analysis for linting, and Connectivity Plus for network awareness. These cover 90% of what any production app needs.
Should I use Riverpod or BLoC for Flutter state management?
For most projects in 2026, Riverpod is the better choice — it offers compile-time safety, no BuildContext dependency, excellent testability, and less boilerplate than BLoC. BLoC still makes sense for large enterprise teams that want strict unidirectional data flow patterns. If you're starting a new project, go with Riverpod unless your team already has deep BLoC expertise.
Is Hive still the best local database for Flutter?
No. In 2026, Drift has surpassed Hive for most use cases. Drift gives you type-safe SQL queries, reactive streams, complex relations, migrations, and works on every platform including web. Hive is still fine for simple key-value storage, but for anything with relational data or complex queries, Drift is the clear winner.
How do I choose between Dio and the http package in Flutter?
Use Dio for any production app. It supports interceptors for auth tokens, request cancellation, file uploads with progress tracking, automatic retries, and response transformation out of the box. The built-in http package is fine for prototypes, but you'll end up reinventing Dio's features one by one as your app grows.
What navigation package should I use in Flutter 2026?
GoRouter is the standard choice — it's maintained by the Flutter team, supports deep linking, URL-based navigation, nested routes, shell routes for bottom nav bars, and redirect guards. For simple apps with 5-10 screens, you can use Navigator 2.0 directly, but GoRouter eliminates so much boilerplate that it's worth adding even for small projects.
Do I really need Freezed in my Flutter project?
If your app has more than 5 data models, yes. Freezed generates immutable data classes with copyWith, equality, JSON serialization, sealed unions, and pattern matching — all from a single class definition. Without Freezed, you'll write hundreds of lines of boilerplate that's error-prone and tedious to maintain.
Which Flutter packages should beginners learn first?
Start with GoRouter for navigation, then Dio for API calls, then Riverpod for state management. These three cover the core skills every Flutter developer needs. Once you're comfortable, add Freezed for data models and Cached Network Image for loading remote images. Don't try to learn all 10 packages at once — master the fundamentals first.
How many packages is too many in a Flutter project?
I get suspicious when a project has more than 30 direct dependencies. Every package is a maintenance liability — it can break on Flutter upgrades, get abandoned by its author, or introduce security vulnerabilities. I evaluate each package against three criteria: is it actively maintained, does it have good test coverage, and could I reasonably build this myself in under a day? If yes to the third, I often skip the package.