devShakib

Publishing a Flutter Package People Actually Depend On

Publish a Flutter package on pub.dev people actually depend on: API design, semantic versioning, null safety, pub score, docs, testing, and federated plugins.

I once shipped a package that broke someone's production app at 2am their time, from a change I thought was harmless. I'd flipped a field from nullable to non-nullable — a "cleanup" — and it took down every caller who was still passing null. The GitHub issue was polite. It shouldn't have been. That bug taught me more about Flutter package authoring than any tutorial ever did.

Getting a package onto pub.dev is a Saturday afternoon: flutter create --template=package, a dartdoc comment or two, dart pub publish, done. Green checkmark, a version number, and nobody using it. The hard part starts the moment a stranger types your package name into their pubspec.yaml. Now you own a public API you can't quietly change, a pub.dev score people judge you by before they read a line, and a small implicit promise: this won't break my build next Tuesday. I've shipped Flutter and Dart packages that got real adoption and packages that deserved to die in obscurity, and the gap between them was almost never the idea. It was the boring engineering around the idea — semantic versioning discipline, a tiny API surface, real tests, and honest docs. Here's the package-authoring checklist I wish someone had handed me.

Package vs plugin vs federated plugin: know what you're building

Dart has three shapes and people conflate them constantly. Picking the wrong one is the most expensive early mistake, because switching later is a breaking change on your users.

The mistake I see most: authors reach for a plugin when a package will do. If your logic can run in pure Dart, keep it pure Dart. No MethodChannel, no native build toolchains, no per-platform CI matrix, no App Store review coupling. You'll thank yourself for years. Only cross into plugin territory when you genuinely need something the OS owns.

A quick gut check before you scaffold: does the feature require calling a platform SDK, touching hardware, or reading OS-level state? If not, it's a package. If yes — and you expect the package to matter — structure it federated from day one. Retrofitting federation onto a monolithic plugin later is a breaking change and a weekend you won't enjoy. More on the mechanics near the end.

API design: you have to live in this house

Your public API is the one thing you can't refactor freely, because refactoring it breaks other people's code. Treat every public symbol as a contract you're signing in ink.

A few rules I hold to:

Make the API surface as small as it can be. Everything public is a promise. Put implementation details in lib/src/ and only export what users truly need. In Dart the convention is a single top-level library file that re-exports the intended surface:

// lib/my_package.dartlibrary my_package;export 'src/client.dart' show ApiClient;export 'src/models.dart' show User, Session;// note: src/internal_cache.dart is NOT exported — it stays private

Anything you don't export, you can rewrite tomorrow. Anything you export, you're stuck with until a major version bump. When in doubt, don't export it — you can always add to the surface later without breaking anyone, but you can never quietly remove.

Prefer named parameters for anything with more than two arguments. Positional arguments lock you into an order forever; you can't insert a new one without breaking callers. Named parameters let the API grow gracefully, and they read better at the call site.

// Painful to evolve — adding a param means a new positional slotFuture<Result> fetch(String url, Duration timeout, bool retry);// Grows without breaking anyone; sensible defaults keep the common call shortFuture<Result> fetch(  String url, {  Duration timeout = const Duration(seconds: 30),  bool retry = false,});

Design for the caller who read zero docs. The best API is one where the wrong thing doesn't compile. Use types, not stringly-typed magic. An enum or a sealed class beats a String flag every single time. If someone can pass "activ" and get a silent no-op, that's your bug, not theirs. Dart's sealed classes and exhaustive switch make illegal states genuinely unrepresentable — lean on them.

// Fragile: typos compile, invalid values reach runtimevoid setStatus(String status);// Safe: the compiler rejects anything that isn't a real statusenum AccountStatus { active, suspended, closed }void setStatus(AccountStatus status);

Don't leak your dependencies into your public API. If a public method returns a type from some other package, you've just made that package part of your contract. When it ships a breaking change, so do you — on their schedule, not yours. Wrap third-party types or expose your own. This one rule has saved me more forced major releases than anything else.

Semantic versioning and null safety: don't break builds by accident

Semantic versioning in the Dart world is not a suggestion, it's how pub resolves everyone's dependency graph. MAJOR.MINOR.PATCH, and the rule people forget is that in 0.x.y, the minor slot is your breaking slot. 0.4.0 to 0.5.0 can break things; that's expected pre-1.0. Once you hit 1.0.0, the major slot is the only place breaking changes are allowed. This matters because callers pin you with caret constraints like ^1.2.0, which means "anything >=1.2.0 <2.0.0" — they are trusting your minor and patch bumps to be safe.

What actually counts as a breaking change, and bites people who don't think about it:

That last one gets people. Bumping environment: sdk in your pubspec.yaml is a breaking change for anyone on an older SDK, even if you touched no code. Be deliberate about it, and mention it prominently in your changelog.

Null safety is where a lot of accidental breakage lives — it's the exact trap I fell into. A field's nullability is part of the type contract. Flipping String? to String breaks every caller who was passing null; going the other way breaks everyone who assumed it was always there. Neither direction is safe to sneak into a patch release. Decide nullability carefully up front, because changing your mind later is a major version and nothing less.

Here's an opinion I'll defend: a package should never take a dependency it wouldn't be comfortable pinning for a year. Every dependency you add is a breaking change waiting to happen on someone else's schedule. If some_http_helper cuts a major release and you've exposed its types, you're now forced into a major release too, whether you had anything to say about it or not. I keep the dependency list of a published package deliberately short and boring, and I widen version constraints (^) rather than pinning exact versions, so my package composes cleanly with everyone else's dependency graph.

One discipline that saved me: I keep an API-diff check in CI — dart_apitool or a hand-maintained golden snapshot of the public surface. Before every publish it diffs the current public API against the last release and fails the build if something changed without a matching version bump. Machines catch the accidental breaks that code review misses at 11pm.

The pub.dev score, demystified

pub.dev grades every package on a points system, and people obsess over the number without knowing what moves it. It's not mysterious. There are roughly six buckets that make up the score:

The single highest-leverage move is running the analysis locally before you ship, using the same tool pub.dev runs:

# Preview the exact bundle pub.dev will receivedart pub publish --dry-run# Run the same analyzer pub.dev uses and see your score breakdowndart pub global activate panapana .

pana prints your score breakdown line by line and tells you exactly which points you're leaving on the table. I run it in CI and fail the build if the score drops. No guessing, no surprises the morning after a publish.

Two things worth internalizing. First, the score is not a popularity metric — likes and downloads are separate signals. Second, a perfect score doesn't make your package good. But a bad score is a signal to prospective users that you don't sweat the details, and they'll pass. Get the free points. All of them.

Docs and the example/ app that does the selling

Nobody reads your source. They read your README, they skim your example, and if both are good they trust you. If your README opens with a badge wall and three paragraphs about your philosophy, you've already lost them.

My README template, in order:

Then the example/ directory. This isn't a formality — pub.dev renders it prominently, and it's where a serious evaluator goes to decide whether your API is pleasant to use. A real, runnable example/ app is worth more than any amount of prose. Make it a proper Flutter app if you're shipping UI, keep it minimal and focused, and make sure it actually compiles against the published version. I've been burned by an example that referenced an unreleased API; it looked great and worked for no one.

For dartdoc comments, write to answer "why" and "when", not "what". /// The user's ID. is noise. /// The Firebase UID; null until the first successful sign-in. earns its place. Document the null cases, what a method throws, the units of a number, and any threading or platform assumptions. Good doc comments are the difference between an API that feels considered and one that feels like a maze.

Testing across platforms and Flutter versions

A package that passes on your machine and your Flutter channel is a package that works exactly once. Real dependability means proving it across the versions and platforms your users are actually on. Two axes matter, and CI is the only sane way to cover them.

The Flutter version axis. Test against your declared minimum SDK and the current stable, at minimum. People pin old Flutter for all sorts of reasons; if you claim to support >=3.10.0, prove it. A GitHub Actions version matrix is cheap and boring, which is exactly what you want from CI:

jobs:  test:    strategy:      matrix:        flutter: ['3.19.0', 'stable']    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2        with:          flutter-version: ${{ matrix.flutter }}      - run: flutter pub get      - run: dart format --set-exit-if-changed .      - run: flutter analyze --fatal-infos      - run: flutter test --coverage

The platform axis. Pure Dart packages are portable and you mostly don't sweat this. Plugins are the opposite — a plugin has to be tested on every OS it claims to support, which means integration_test runs on real or emulated devices. Fan the matrix across runs-on: [ubuntu-latest, macos-latest, windows-latest] and run integration tests where native behavior actually lives. It's slower and more annoying, and it's the whole point of shipping a plugin responsibly.

Keep unit tests fast and pure. Push anything that touches a platform channel into integration tests, and provide a fake platform implementation so your unit suite never needs a device. A well-designed platform interface (see the next section) makes this easy: you swap in a fake in setUp() and assert against it.

Federated plugins done right

If you're maintaining a plugin that spans platforms, federation is how you keep it sane. The pattern has three kinds of package:

The platform interface should extend PlatformInterface and use its token verification. This isn't ceremony — it stops someone from implementing your interface with a plain implements and silently skipping methods you add later, which would surface as a runtime crash on a platform you don't own:

abstract class MyPluginPlatform extends PlatformInterface {  MyPluginPlatform() : super(token: _token);  static final Object _token = Object();  static MyPluginPlatform _instance = MethodChannelMyPlugin();  static MyPluginPlatform get instance => _instance;  static set instance(MyPluginPlatform instance) {    PlatformInterface.verifyToken(instance, _token);    _instance = instance;  }  Future<String?> getPlatformVersion() {    throw UnimplementedError('getPlatformVersion() has not been implemented.');  }}

Endorsement is the piece people miss. When the app-facing package declares a default implementation for a platform in its pubspec.yaml, that implementation is "endorsed" — users get it automatically just by depending on my_plugin, without adding my_plugin_android themselves. Endorsing your own implementations is what makes federation invisible to the consumer. Leaving them unendorsed forces every user to wire up each platform by hand, and they'll resent it.

The payoff justifies the extra packages: when Android needs a fix, you bump my_plugin_android alone. Nobody on iOS gets a churned dependency or a surprise rebuild. Third parties can even ship an implementation for a platform you never supported — a Linux backend, say — without your involvement or a PR to your repo. That's federation earning its complexity.

Maintaining it: the unglamorous part

Publishing is the beginning. What separates a package people depend on from one they migrate away from is how you behave over the next two years.

Deprecate, don't delete. When you need to change an API, add the new thing, mark the old thing, and give people a full major version to migrate:

@Deprecated('Use fetchUser() instead. Will be removed in 3.0.0.')Future<User> getUser(String id) => fetchUser(id: id);

The @Deprecated message must name the replacement and the removal version. A deprecation without a migration path is just a rude warning that clutters someone's analyzer output.

Write changelogs for humans. "Bug fixes and improvements" is contempt for your users. Say what changed, what broke, and how to migrate. Lead every entry that requires action with a clear BREAKING tag. When someone's build fails after an upgrade, your CHANGELOG.md is the first place they look — respect that, and follow the "Keep a Changelog" convention so entries are scannable.

Say no. This is the skill that keeps a package healthy long-term. Every feature request is someone asking you to expand the surface you're obligated to maintain forever. Most should be politely declined. A tight package that does one thing well outlives the kitchen-sink package that tried to please everyone and collapsed under its own configuration options. "That's a great use case for a wrapper around this package" is a complete and honest answer.

Automate the boring gates. Version-bump enforced in CI, pana score checked, dart format and flutter analyze fatal, an API-diff on the public surface. Once these run on every pull request, you stop shipping accidents, and maintenance stops being scary. The goal is a pipeline where the only way to break a user is to decide to — never to slip.

Key takeaways