DEV Community

Cover image for How to Build and Publish a Dart API Package to pub.dev
Franklin Oladipo
Franklin Oladipo

Posted on

How to Build and Publish a Dart API Package to pub.dev

A practical, real-world guide to structuring Dart packages, understanding plugins, and publishing reusable libraries to pub.dev.

One of the strengths of the Dart and Flutter ecosystem is how easy it is to share reusable code. From tiny helper functions to large architectural abstractions, pub.dev serves as the central marketplace where developers discover and depend on each other’s work.

In this article, we’ll walk through building a simple Dart package from scratch and publishing it to pub.dev, while also clarifying the often-confused distinction between packages and plugins.

Packages and Plugins Are Not the Same Thing

If you’ve spent any time browsing pub.dev, you’ve probably noticed that some libraries are described as packages while others are called plugins. Although they live in the same registry, they serve different purposes.

A package is simply reusable Dart code. It does not care about the operating system, device hardware, or native APIs. Packages are ideal for things like validation logic, formatting helpers, algorithms, state management utilities, and other forms of business logic that can run anywhere Dart runs.

A plugin, on the other hand, exists specifically to connect Flutter to the underlying platform. If your code needs to access the camera, read the device location, interact with Bluetooth, or call into Android or iOS SDKs, you are building a plugin. Plugins contain Dart code, but also include platform-specific implementations that communicate over platform channels.

A useful mental model is this: if your library could theoretically run on the Dart VM without Flutter, it should probably be a package. If it depends on the operating system, it must be a plugin.

For the rest of this article, we’ll focus on building and publishing a package called rest_countries_package, which wraps the public REST Countries API. The benefit of building this package is to build an abstraction layer where the consumers do not need to know about the implementations but focus on the usage by making direct method calls and getting results.

Creating a New Dart Package

The Dart CLI makes it straightforward to scaffold a new package.
From your terminal, run:

dart create rest_countries_package
This command generates a basic but complete project structure. You’ll see a lib directory for your public API, a test directory for unit tests, and a handful of files such as pubspec.yaml, README.md, CHANGELOG.md, and LICENSE. These files are not optional extras, they are essential for a package that’s ready to be published.

At this point, you already have something that looks like a real pub.dev package (I added the extra files we would need).

rest_countries_package/
├── pubspec.yaml
├── pubspec.lock
├── README.md
├── CHANGELOG.md
├── LICENSE
├── analysis_options.yaml
├── .gitignore
├── .metadata
├── .github/
├── assets/
├── example/
   └── main.dart
├── lib/
   ├── rest_countries_data.dart
   └── src/
       ├── data/
          ├── api_helper.dart
          └── countries_api.dart
       ├── domain/
          ├── country_model.dart
          └── enums/
              └── country_fields.dart
       └── repository/
           ├── countries_repository.dart
           └── countries_repository_impl.dart
├── test/
Enter fullscreen mode Exit fullscreen mode

Everything inside the lib directory defines what users of your package can import. By convention, there is a main file that shares the same name as the package itself.

Handling HTTP and Errors Cleanly
At the lowest level of the package is the API helper, located in lib/src/data/api_helper.dart that contains a callAPI() method.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;

class ApiHelper {
  static const String baseUrl = 'https://restcountries.com/v3.1';
  Future<List<Map<String, dynamic>>> callAPI({
    required String apiUrl,
  }) async {
    try {
      http.Response response =
          await http.get(Uri.parse('$baseUrl${apiUrl.trim()}'));
      if (response.statusCode == 200) {
        List<Map<String, dynamic>> rawData =
            List<Map<String, dynamic>>.from(jsonDecode(response.body));
        if (rawData.isEmpty) {
          throw Exception('No country found. Specify a valid field');
        }
        return rawData;
      } else if (response.statusCode == 400) {
        throw Exception(
            'Bad Request: You may have specified an unsupported field or invalid country data.');
      } else if (response.statusCode == 404) {
        throw Exception('Country not found');
      } else if (response.statusCode >= 500) {
        throw Exception('Server error: ${response.statusCode}');
      } else {
        throw Exception('API error: ${response.statusCode} - ${response.body}');
      }
    } on SocketException {
      throw Exception('No internet connection');
    } on FormatException {
      throw Exception('Invalid response format');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This class exists for one reason: centralizing HTTP behavior. Every request flows through this method, which means error handling, response validation, and decoding are implemented once and reused everywhere.

Instead of returning raw strings or loosely typed objects, the helper decodes the response into a List>. This keeps the networking layer generic while allowing higher layers to decide how that data should be interpreted.

The method also translates HTTP status codes into meaningful Dart exceptions. A consumer of the package never needs to check status codes or handle socket exceptions directly, the package does that work for them.

Expressive API Endpoints with CountriesApi

The CountriesApi class builds on top of ApiHelper and represents the actual REST endpoints.

import 'package:rest_countries_data/src/data/api_helper.dart';
import 'package:rest_countries_data/src/domain/enums/country_fields.dart';

class CountriesApi {
  final ApiHelper apiHelper = ApiHelper();

  Future<List<Map<String, dynamic>>> getAllCountries({
    required List<CountryFields> fields,
  }) async {
    final String apiQueryFields = buildCountryQueryFields(
      countryFields: fields,
    );

    return await apiHelper.callAPI(
      apiUrl: '/all?fields=$apiQueryFields',
    );
  }

  Future<List<Map<String, dynamic>>> getCountryByCapital({
    required String capital,
  }) async {
    return await apiHelper.callAPI(apiUrl: '/capital/$capital');
  }

 // ...

}

// Helper method
String buildCountryQueryFields({required List<CountryFields> countryFields}) {
  return countryFields
      .map((CountryFields field) => field.apiValue)
      .toList()
      .join(',');
}
Enter fullscreen mode Exit fullscreen mode

Each method corresponds directly to an endpoint exposed by the REST Countries API. This class does not perform validation or mapping, it simply knows what endpoint to call and how to build the URL.

One particularly useful method is getAllCountries, which supports field filtering. This is where the CountryFields enum comes into play.

Strongly Typed Query Fields with Enums

The REST Countries API allows clients to limit response size by specifying fields. Instead of forcing users to pass raw strings, the package introduces a CountryFields enum.

enum CountryFields {
  name,
  cca2,
  cca3,
  population,
  region,
  // ...
}
Enter fullscreen mode Exit fullscreen mode

An extension maps each enum value to the API’s expected string:

extension CountryFieldsExtension on CountryFields {
  String get apiValue {
    switch (this) {
      case CountryFields.cca2:
        return "cca2";
      case CountryFields.name:
        return "name";
      // ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach provides compile-time safety, autocomplete support, and prevents subtle bugs caused by typos in query strings. A small helper function then converts the selected fields into a comma-separated query value.

Modelling the API Response

At the heart of the package is CountryModel, found in lib/src/domain/country_model.dart. This model mirrors the structure of the REST Countries response, including nested objects such as names, currencies, flags, maps, and translations.

class CountryModel {
  final Name? name;
  final String? cca2;
  final int? population;
  final Flags? flags;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Each nested structure — Name, Currency, Flags, Maps, and others has its own class and fromJson factory. This keeps parsing logic readable and prevents a single monolithic model from becoming unmanageable.

The result is a strongly typed representation of country data that consumers can use confidently without manual JSON parsing.

Repository Layer: Validation and Mapping

The repository layer is where raw API data becomes domain models.

import 'package:rest_countries_data/src/data/countries_api.dart';
import 'package:rest_countries_data/src/domain/country_model.dart';
import 'package:rest_countries_data/src/domain/enums/country_fields.dart';
import 'package:rest_countries_data/src/repository/countries_repository.dart';

/// Repository implementation for accessing country data via [CountriesApi].
///
/// Provides methods to fetch countries by various criteria such as capital,
/// region, language, currency, and more.
class CountriesRepositoryImpl implements CountryRepository {
  final CountriesApi countriesApi;

  /// Creates a new [CountriesRepositoryImpl] with the given [countriesApi].
  CountriesRepositoryImpl(this.countriesApi);

  /// Retrieves all countries with the specified [fields].
  ///
  /// Returns a list of [CountryModel].
  @override
  Future<List<CountryModel>> getAllCountries({
    required List<CountryFields> fields,
  }) async {
    if (fields.length > 10) {
      throw Exception('CountryFields cannot be more than 10');
    }

    if (fields.isEmpty) {
      throw Exception('CountryFields cannot be empty');
    }

    List<Map<String, dynamic>> response =
        await countriesApi.getAllCountries(fields: fields);

    List<CountryModel> countryModelList = response
        .map((Map<String, dynamic> country) => CountryModel.fromJson(country))
        .toList();

    return countryModelList;
  }

  /// Retrieves countries whose capital city matches [capital].
  ///
  /// Returns a list of [CountryModel].
  @override
  Future<CountryModel> getCountryByCapital({
    required String capital,
  }) async {
    List<Map<String, dynamic>> response =
        await countriesApi.getCountryByCapital(capital: capital);

    return CountryModel.fromJson(response.first);
  }

  /// More functions comes here.
}
Enter fullscreen mode Exit fullscreen mode

This layer performs two important tasks. First, it validates inputs. For example, when fetching all countries, it enforces a maximum number of requested fields to prevent overly large responses.

Second, it maps raw JSON into CountryModel instances. This ensures that everything exposed to the outside world is strongly typed and consistent.

By depending on an abstract CountryRepository, this design also makes the package easier to test and extend in the future.

Exposing a Clean Public API

Consumers of the package never need to know about ApiHelper, CountriesApi, or repository implementations. All interaction happens through a single entry point in lib/rest_countries_data.dart.

export 'src/domain/country_model.dart';
export 'src/domain/enums/country_fields.dart';

import 'package:rest_countries_data/src/data/countries_api.dart';
import 'package:rest_countries_data/src/domain/country_model.dart';
import 'package:rest_countries_data/src/domain/enums/country_fields.dart';
import 'package:rest_countries_data/src/repository/countries_repository_impl.dart';

class RestCountries {
  static final CountriesRepositoryImpl _repo =
      CountriesRepositoryImpl(CountriesApi());

  static Future<List<CountryModel>> getAllCountries({
    required List<CountryFields> fields,
  }) {
    return _repo.getAllCountries(fields: fields);
  }

  static Future<CountryModel> getCountryByCapital({
    required String capital,
  }) {
    return _repo.getCountryByCapital(capital: capital);
  }

  // Other functions comes here
}
Enter fullscreen mode Exit fullscreen mode

This static facade keeps usage simple while still benefiting from a layered internal architecture. It also allows the internal implementation to evolve without breaking users.

Using the Package
The example/main.dart file demonstrates how the package is intended to be consumed.

import 'package:rest_countries_data/rest_countries_data.dart';

void main() async {
  await getAllCountries();
  await getCountryByCode();
  /// Call other functions here
}

void log(String message) {
  //ignore: avoid_print
  print(message);
}


Future<void> getCountryByCode() async {
  try {
    final CountryModel country =
        await RestCountries.getCountryByCode(code: 'NG');
    log('\nCountry with code NG: ${country.name?.official}');
  } catch (e) {
    log('$e');
  }
}


Future<void> getAllCountries() async {
  try {
    final List<CountryModel> countries = await RestCountries.getAllCountries(
        fields: <CountryFields>[CountryFields.name]);
    log('\nAll countries (limited fields):');
    for (final CountryModel country in countries.take(5)) {
      log('- ${country.name?.common}');
    }
  } catch (e) {
    log('$e');
  }
}

/// Other functions comes here
}
Enter fullscreen mode Exit fullscreen mode

`
From the user’s perspective, this feels like a native Dart API. They don’t deal with URLs, HTTP clients, or JSON parsing only meaningful methods and domain models.

Preparing for pub.dev

Once the core functionality of the package is complete, the next step is preparing it for publication on pub.dev. Unlike pushing code to a Git repository, publishing to pub.dev requires an authenticated publisher account, so there are a few one-time setup steps to complete before your first release.

Before you can publish anything, you need a pub.dev account. pub.dev uses Google authentication, so signing in is as simple as visiting pub.dev in your browser and logging in with a Google account. This step is important because the email address you use becomes the owner of any package you publish and will be associated with future updates.

After signing in on the web, you also need to authenticate locally from your terminal. When you run a publish command for the first time, Dart will prompt you to complete an authentication flow. This connects your local environment to your pub.dev account and allows you to publish packages from the command line.

With authentication in place, you can now focus on package readiness. The first file to review is pubspec.yaml. This file defines how your package is identified and discovered. The name must be unique on pub.dev, the description should clearly explain what problem the package solves, and the version must follow semantic versioning. For an initial release, a version like 1.0.0 communicates stability and intent. Accurate SDK constraints are also important so consumers know which Dart versions are supported.

The next critical piece is README.md. On pub.dev, this file is effectively your package’s landing page. It should explain what the package does, how to install it, and how to use it with at least one realistic example. For the REST Countries package, demonstrating how to fetch countries by region or retrieve a country by code helps users immediately understand how the API fits into their projects.

The CHANGELOG.md plays a supporting but essential role. Even for a first release, documenting the initial feature set establishes a baseline and signals that the package will be maintained in a structured way as it evolves.

Before actually publishing, it’s strongly recommended to run a dry publish:

dart pub publish --dry-run
This command validates the package without releasing it. It checks for missing metadata, formatting issues, and other common problems that could negatively affect your package’s pub.dev score. Any warnings reported here should be treated as issues to fix before continuing.

Once the dry run passes cleanly, publishing the package is as simple as running:

dart pub publish
At this point, Dart will confirm the authenticated account, prompt you to review the package contents, and finalize the release. After publication, the package becomes immediately available on pub.dev and can be consumed across Flutter applications, backend services, and CLI tools without any additional setup.

With that, your package officially becomes part of the Dart ecosystem — discoverable, reusable, and ready for others to build on.

Final Thoughts

This project is a great example of what a well-structured Dart package looks like. It abstracts complexity behind clear boundaries, enforces correctness through strong typing, and exposes a clean, approachable API that feels natural to use. The full package is available on pub.dev, where it can be easily discovered and added to any Dart or Flutter project: https://pub.dev/packages/rest_countries_data

More importantly, it demonstrates that packages don’t have to be trivial to be reusable. Thoughtful architecture — layered APIs, domain models, and repositories — belongs in shared libraries just as much as it does in applications. For those interested in exploring the implementation details or contributing improvements, the complete source code is available on GitHub:

If you found this article useful, please don’t forget to clap 👏 and leave a comment. If you have any questions too, please leave a comment, I will reply everyone of them. Thanks for reading.

In case you want to reach me on other social media accounts, these are my profiles:

Github: https://github.com/frankdroid7
Twitter: https://twitter.com/Frankdroid77
Instagram: https://www.instagram.com/mobiledevgenie/
LinkedIn: https://www.linkedin.com/in/franklin-oladipo/

Top comments (0)