
Complete Indonesia location dataset down to postal codes, with type-safe models, O(1) ID lookups, Levenshtein typo-tolerant search, offline embedding, and ready-to-use UI components.
A Kotlin Multiplatform (KMP) library providing complete Indonesia location data — from Province down to Postal Code.
Province → City/Regency → District → Village → Postal Code
| Data Type | Count |
|---|---|
| Provinces | 38 |
| Cities/Regencies | 514 |
| Districts (Kecamatan) | 7,265 |
| Villages (Kelurahan/Desa) | 83,202 |
| Postal Codes | 83,762 |
Data sourced from Kepmendagri No 300.2.2-2138 Year 2025 (latest official data).
| Feature | Description |
|---|---|
| 📍 Complete Data | All 38 Indonesian provinces with complete data down to postal codes |
| 🔀 Multiplatform | Supports Android and iOS in a single codebase |
| 🔒 Type-Safe | Full Kotlin data classes with proper typing |
| ⚡ Fast Lookup | O(1) lookup by ID using indexed HashMap |
| 🔍 Smart Search | Typo-tolerant search using Levenshtein distance algorithm |
| 📴 Offline | Data embedded in library, no internet required |
| 🏛️ BPS Standard | Uses official Statistics Indonesia (BPS) codes |
| 🎨 UI Components | Ready-to-use Compose Multiplatform UI components |
| 📱 Sample App | Complete sample Android app included |
Add dependency to your build.gradle.kts:
dependencies {
implementation("io.github.naufalprakoso.nusantara:data:1.0.2")
}Or in build.gradle (Groovy):
dependencies {
implementation 'io.github.naufalprakoso.nusantara:data:1.0.2'
}✅ Maven Central is enabled by default in most Gradle projects. No additional repository configuration needed!
Clone repository and add as a module:
git clone https://github.com/naufalprakoso/nusantara-data-kotlin.gitIn settings.gradle.kts:
includeBuild("path/to/nusantara-data-kotlin") {
dependencySubstitution {
substitute(module("io.github.naufalprakoso.nusantara:data")).using(project(":nusantara-data"))
}
}Add to app/build.gradle.kts:
android {
// Minimum SDK 24
defaultConfig {
minSdk = 24
}
}
dependencies {
implementation("io.github.naufalprakoso.nusantara:data:1.0.2")
}In Podfile:
pod 'NusantaraData', :git => 'https://github.com/naufalprakoso/nusantara-data-kotlin.git'.package(url: "https://github.com/naufalprakoso/nusantara-data-kotlin.git", from: "1.0.2")Call initialize() once at app startup:
import io.github.naufalprakoso.nusantara.data.NusantaraData
// In Application class (Android) or AppDelegate (iOS)
class MyApplication : Application() {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
// Initialize in background thread
applicationScope.launch {
NusantaraData.initialize()
}
}
}import io.github.naufalprakoso.nusantara.data.NusantaraData
class LocationViewModel : ViewModel() {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _provinces = MutableStateFlow<List<ProvinceSummary>>(emptyList())
val provinces: StateFlow<List<ProvinceSummary>> = _provinces.asStateFlow()
init {
viewModelScope.launch {
// Initialize data
NusantaraData.initialize()
_isInitialized.value = true
// Load provinces after initialization
_provinces.value = NusantaraData.getAllProvinces()
}
}
}
⚠️ Important:initialize()is asuspend function. Run it in a coroutine or background thread.
// ✅ Get all provinces
val provinces = NusantaraData.getAllProvinces()
// Output: [ProvinceSummary(id="11", name="Aceh", cityCount=23), ...]
// ✅ Get province by ID
val jakarta = NusantaraData.getProvinceById("31")
// Output: Province(id="31", name="DKI Jakarta", cities=[...])
// ✅ Get cities in a province
val cities = NusantaraData.getCitiesByProvinceId("31")
// Output: [CitySummary(id="3171", name="Kota Jakarta Pusat", ...), ...]
// ✅ Get districts in a city
val districts = NusantaraData.getDistrictsByCityId("3171")
// Output: [DistrictSummary(id="317101", name="Gambir", ...), ...]
// ✅ Get villages in a district
val villages = NusantaraData.getVillagesByDistrictId("317101")
// Output: [VillageSummary(id="3171012001", name="Gambir", postalCodes=["10110"]), ...]
// ✅ Search locations
val results = NusantaraData.searchProvinces("jawa")
// Output: [Jawa Barat, Jawa Tengah, Jawa Timur]
// ✅ Reverse lookup by postal code
val locations = NusantaraData.getLocationByPostalCode("10110")
// Output: [PostalCodeDetail(postalCode="10110", villageName="Gambir", ...)]// Get all provinces (38 provinces)
NusantaraData.getAllProvinces(): List<ProvinceSummary>
// Get province by BPS ID (2 digits)
NusantaraData.getProvinceById(id: String): Province?
// Get province by name (case-insensitive)
NusantaraData.getProvinceByName(name: String): Province?
// Search provinces by name (typo-tolerant)
NusantaraData.searchProvinces(query: String, tolerance: Int? = null): List<ProvinceSummary>// Get ALL cities in Indonesia (514 cities)
NusantaraData.getAllCities(): List<CitySummary>
// Get all cities in a province
NusantaraData.getCitiesByProvinceId(provinceId: String): List<CitySummary>
// Get city by BPS ID (4 digits)
NusantaraData.getCityById(id: String): City?
// Get city by name (optional: filter by province)
NusantaraData.getCityByName(name: String, provinceId: String? = null): City?
// Search cities by name (typo-tolerant, optional: filter by province)
NusantaraData.searchCities(query: String, provinceId: String? = null, tolerance: Int? = null): List<CitySummary>// Get ALL districts in Indonesia (7,265 districts)
NusantaraData.getAllDistricts(): List<DistrictSummary>
// Get all districts in a city
NusantaraData.getDistrictsByCityId(cityId: String): List<DistrictSummary>
// Get district by BPS ID (6 digits)
NusantaraData.getDistrictById(id: String): District?
// Get district by name (optional: filter by city)
NusantaraData.getDistrictByName(name: String, cityId: String? = null): District?
// Search districts by name (typo-tolerant, optional: filter by city)
NusantaraData.searchDistricts(query: String, cityId: String? = null, tolerance: Int? = null): List<DistrictSummary>// Get ALL villages in Indonesia (83,202 villages)
NusantaraData.getAllVillages(): List<VillageSummary>
// Get all villages in a district
NusantaraData.getVillagesByDistrictId(districtId: String): List<VillageSummary>
// Get village by BPS ID (10 digits)
NusantaraData.getVillageById(id: String): Village?
// Get village by name (optional: filter by district)
NusantaraData.getVillageByName(name: String, districtId: String? = null): Village?
// Search villages by name (typo-tolerant, districtId required for performance)
NusantaraData.searchVillages(query: String, districtId: String, tolerance: Int? = null): List<VillageSummary>// Get postal codes for a village
NusantaraData.getPostalCodesByVillageId(villageId: String): List<String>
// Get all postal codes in a district
NusantaraData.getPostalCodesByDistrictId(districtId: String): List<String>
// Reverse lookup: get full location by postal code
NusantaraData.getLocationByPostalCode(postalCode: String): List<PostalCodeDetail>// Get library version
NusantaraData.getVersion(): String // "1.0.2"
// Get data last updated date
NusantaraData.getLastUpdated(): String // "2026-01-29"
// Get data statistics
NusantaraData.getStatistics(): DataStatisticsdata class Province(
val id: String, // BPS code (2 digits), e.g., "31"
val name: String, // e.g., "DKI Jakarta"
val cities: List<City>
)
data class ProvinceSummary(
val id: String,
val name: String,
val cityCount: Int // Number of cities/regencies
)data class City(
val id: String, // BPS code (4 digits), e.g., "3171"
val provinceId: String,
val name: String, // e.g., "Kota Jakarta Pusat"
val type: CityType, // KOTA or KABUPATEN
val districts: List<District>
)
data class CitySummary(
val id: String,
val provinceId: String,
val name: String,
val type: CityType,
val districtCount: Int
)
enum class CityType {
KOTA, // City
KABUPATEN // Regency
}data class District(
val id: String, // BPS code (6 digits), e.g., "317101"
val cityId: String,
val name: String, // e.g., "Gambir"
val villages: List<Village>
)
data class DistrictSummary(
val id: String,
val cityId: String,
val name: String,
val villageCount: Int
)data class Village(
val id: String, // BPS code (10 digits), e.g., "3171012001"
val districtId: String,
val name: String, // e.g., "Kelurahan Gambir"
val postalCodes: List<String>
)
data class VillageSummary(
val id: String,
val districtId: String,
val name: String,
val postalCodes: List<String>
)data class PostalCodeDetail(
val postalCode: String, // e.g., "10110"
val villageId: String,
val villageName: String,
val districtId: String,
val districtName: String,
val cityId: String,
val cityName: String,
val cityType: CityType,
val provinceId: String,
val provinceName: String
) {
// Get formatted full address
fun getFullAddress(): String
// Output: "Gambir, Gambir, Kota Jakarta Pusat, DKI Jakarta 10110"
}This library uses official BPS (Statistics Indonesia) codes:
| Level | Digits | Format | Example | Description |
|---|---|---|---|---|
| Province | 2 | XX |
31 |
DKI Jakarta |
| City/Regency | 4 | XXXX |
3171 |
Kota Jakarta Pusat |
| District | 6 | XXXXXX |
317101 |
Gambir |
| Village | 10 | XXXXXXXXXX |
3171012001 |
Kelurahan Gambir |
| Postal Code | 5 | XXXXX |
10110 |
Postal Code |
💡 Tips: Child level IDs always start with parent level IDs. Example: City
3171starts with province31.
Implementing an address form with cascading dropdowns:
class AddressFormViewModel : ViewModel() {
// State
var selectedProvince by mutableStateOf<ProvinceSummary?>(null)
var selectedCity by mutableStateOf<CitySummary?>(null)
var selectedDistrict by mutableStateOf<DistrictSummary?>(null)
var selectedVillage by mutableStateOf<VillageSummary?>(null)
var selectedPostalCode by mutableStateOf<String?>(null)
// Options
val provinces = NusantaraData.getAllProvinces()
var cities by mutableStateOf<List<CitySummary>>(emptyList())
var districts by mutableStateOf<List<DistrictSummary>>(emptyList())
var villages by mutableStateOf<List<VillageSummary>>(emptyList())
var postalCodes by mutableStateOf<List<String>>(emptyList())
fun onProvinceSelected(province: ProvinceSummary) {
selectedProvince = province
selectedCity = null
selectedDistrict = null
selectedVillage = null
selectedPostalCode = null
// Load cities for selected province
cities = NusantaraData.getCitiesByProvinceId(province.id)
districts = emptyList()
villages = emptyList()
postalCodes = emptyList()
}
fun onCitySelected(city: CitySummary) {
selectedCity = city
selectedDistrict = null
selectedVillage = null
selectedPostalCode = null
// Load districts for selected city
districts = NusantaraData.getDistrictsByCityId(city.id)
villages = emptyList()
postalCodes = emptyList()
}
fun onDistrictSelected(district: DistrictSummary) {
selectedDistrict = district
selectedVillage = null
selectedPostalCode = null
// Load villages for selected district
villages = NusantaraData.getVillagesByDistrictId(district.id)
postalCodes = emptyList()
}
fun onVillageSelected(village: VillageSummary) {
selectedVillage = village
// Set postal codes (usually 1, sometimes multiple)
postalCodes = village.postalCodes
selectedPostalCode = postalCodes.firstOrNull()
}
}Implementing search with debounce:
class SearchViewModel : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchResults: StateFlow<List<CitySummary>> = _searchQuery
.debounce(300) // Wait 300ms after user stops typing
.map { query ->
if (query.length >= 2) {
NusantaraData.searchCities(query)
} else {
emptyList()
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
}All search functions are typo-tolerant by default:
// User types "Jakrta" instead of "Jakarta" - still works!
val results = NusantaraData.searchProvinces("Jakrta")
// Results: [ProvinceSummary(name="DKI Jakarta", ...)]
// User types "Bandug" instead of "Bandung"
val cities = NusantaraData.searchCities("Bandug")
// Results: [CitySummary(name="Kota Bandung", ...), CitySummary(name="Kabupaten Bandung", ...)]
// Common Indonesian typos are handled:
// "Jogjakarta" → matches "Yogyakarta"
// "Surabya" → matches "Surabaya"
// "Denpassar" → matches "Denpasar"
// "Makasar" → matches "Makassar"
// Use custom tolerance for stricter/looser matching
val strictResults = NusantaraData.searchCities("Bandug", tolerance = 1)
val looseResults = NusantaraData.searchCities("Bndung", tolerance = 3)
// Filter by parent region for better performance
val jakartaDistricts = NusantaraData.searchDistricts(
query = "Gambr",
cityId = "3171" // Jakarta Pusat only
)Get full address from postal code:
fun getAddressFromPostalCode(postalCode: String): String? {
val locations = NusantaraData.getLocationByPostalCode(postalCode)
return locations.firstOrNull()?.let { location ->
buildString {
appendLine("Village: ${location.villageName}")
appendLine("District: ${location.districtName}")
appendLine("City: ${location.cityName}")
appendLine("Province: ${location.provinceName}")
appendLine("Postal Code: ${location.postalCode}")
}
}
}
// Usage
val address = getAddressFromPostalCode("10110")
// Output:
// Village: Gambir
// District: Gambir
// City: Kota Jakarta Pusat
// Province: DKI Jakarta
// Postal Code: 10110The library includes ready-to-use Compose Multiplatform UI components:
import io.github.naufalprakoso.nusantara.data.ui.*
@Composable
fun AddressForm() {
var selection by rememberLocationPickerState()
// Complete location picker with cascading dropdowns
LocationPicker(
selection = selection,
onSelectionChanged = { selection = it },
labels = LocationPickerLabels.Indonesian // or use English default
)
// Show selected address
if (selection.isComplete) {
Text("Address: ${selection.toAddressString()}")
Text("Postal Code: ${selection.postalCodes.firstOrNull()}")
}
}You can also use individual selectors:
// Province selector
ProvinceSelector(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> /* handle selection */ }
)
// City selector (requires provinceId)
CitySelector(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> /* handle selection */ }
)
// District selector (requires cityId)
DistrictSelector(
cityId = selectedCity?.id,
selectedDistrict = selectedDistrict,
onDistrictSelected = { district -> /* handle selection */ }
)
// Village selector (requires districtId)
VillageSelector(
districtId = selectedDistrict?.id,
selectedVillage = selectedVillage,
onVillageSelected = { village -> /* handle selection */ }
)Use simple dropdown components for basic location selection:
// Province dropdown
ProvinceDropdown(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> selectedProvince = province },
label = "Provinsi",
placeholder = "Pilih Provinsi"
)
// City dropdown (requires provinceId)
CityDropdown(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> selectedCity = city }
)
// District and Village dropdowns work similarly
DistrictDropdown(cityId = selectedCity?.id, ...)
VillageDropdown(districtId = selectedDistrict?.id, ...)Dropdowns with search capability for easier selection:
// Searchable province dropdown
SearchableProvinceDropdown(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> selectedProvince = province },
label = "Provinsi",
placeholder = "Ketik untuk mencari..."
)
// Searchable city dropdown
SearchableCityDropdown(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> selectedCity = city }
)
// District and Village also available
SearchableDistrictDropdown(cityId = selectedCity?.id, ...)
SearchableVillageDropdown(districtId = selectedDistrict?.id, ...)Full-screen dialog for location selection with search:
// Province picker with dialog
ProvincePickerField(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> selectedProvince = province },
label = "Provinsi",
dialogTitle = "Pilih Provinsi"
)
// City picker with dialog
CityPickerField(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> selectedCity = city },
dialogTitle = "Pilih Kota/Kabupaten"
)
// Or use LocationPickerDialog directly for full customization
var showDialog by remember { mutableStateOf(false) }
LocationPickerDialog(
locationType = LocationType.PROVINCE,
isVisible = showDialog,
onDismiss = { showDialog = false },
onItemSelected = { item -> selectedProvince = item as ProvinceSummary },
config = LocationPickerDialogConfig(
title = "Pilih Provinsi",
showSearchBar = true,
showConfirmButton = false // Select immediately on click
)
)All UI components are fully customizable:
// Custom dropdown configuration
LocationDropdown(
locationType = LocationType.PROVINCE,
selectedValue = selectedProvince?.name ?: "",
onValueSelected = { /* handle */ },
config = LocationDropdownConfig(
label = "Province",
placeholder = "Select...",
enabled = true,
isError = hasError,
errorMessage = "Required field",
labelStyle = MaterialTheme.typography.labelLarge,
containerColor = Color.White
),
// Custom item rendering
dropdownItem = { item, onClick ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp)
) {
Text((item as ProvinceSummary).name)
Spacer(Modifier.weight(1f))
Text("${item.cityCount} cities")
}
}
)For large datasets, use search fields with autocomplete:
// Search by postal code
PostalCodeSearchField(
onLocationSelected = { detail ->
println("Found: ${detail.villageName}, ${detail.provinceName}")
}
)
// Search provinces
ProvinceSearchField(
onProvinceSelected = { province ->
println("Selected: ${province.name}")
}
)
// Search cities (optionally filtered by province)
CitySearchField(
provinceId = selectedProvince?.id, // optional filter
onCitySelected = { city ->
println("Selected: ${city.name}")
}
)A complete sample Android app is included in the sample/ module, demonstrating:
# Build and install sample app
./gradlew :sample:installDebug
# Or build APK
./gradlew :sample:assembleDebug
# APK at: sample/build/outputs/apk/debug/sample-debug.apk# Clone repository
git clone https://github.com/naufalprakoso/nusantara-data-kotlin.git
cd nusantara-data-kotlin
# Build all targets
./gradlew :nusantara-data:build
# Run tests
./gradlew :nusantara-data:allTests
# Build Android AAR
./gradlew :nusantara-data:assembleRelease
# Build iOS Framework
./gradlew :nusantara-data:linkDebugFrameworkIosSimulatorArm64nusantara-data-kotlin/
├── build.gradle.kts # Root build config
├── settings.gradle.kts # Project settings
├── gradle.properties # Gradle properties
├── gradle/
│ └── libs.versions.toml # Version catalog
├── nusantara-data/ # Main library module
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/ # Shared code (Kotlin)
│ │ ├── kotlin/io/github/naufalprakoso/nusantara/data/
│ │ │ ├── NusantaraData.kt # Main entry point
│ │ │ ├── model/ # Data classes
│ │ │ ├── repository/ # Repository pattern
│ │ │ ├── datasource/ # Data source
│ │ │ └── ui/ # Compose UI components
│ │ │ ├── LocationPicker.kt # Complete location picker
│ │ │ ├── LocationSelectors.kt # Individual selectors
│ │ │ └── LocationSearchField.kt # Search components
│ │ └── composeResources/files/
│ │ └── indonesia_locations.json # Location data (20MB)
│ ├── commonTest/ # Shared tests
│ ├── androidMain/ # Android-specific code
│ └── iosMain/ # iOS-specific code
├── sample/ # Sample Android app
│ ├── build.gradle.kts
│ └── src/main/kotlin/io/github/naufalprakoso/nusantara/data/sample/
│ └── MainActivity.kt # Demo app
└── README.md
We greatly appreciate contributions from the community! Here's how to contribute:
| Type | Description |
|---|---|
| 🐛 Bug Reports | Report bugs you find |
| 💡 Feature Requests | Suggest new features |
| 📖 Documentation | Fix or add documentation |
| 🔧 Code | Submit pull requests for fixes or features |
| 🌐 Data Updates | Update location data when changes occur |
# Fork repository via GitHub UI, then:
git clone https://github.com/YOUR_USERNAME/nusantara-data-kotlin.git
cd nusantara-data-kotlin# Use naming convention:
# - feature/feature-name
# - fix/bug-name
# - docs/description
git checkout -b feature/my-new-feature# Ensure your code:
# ✅ Follows Kotlin coding conventions
# ✅ Includes unit tests for new features
# ✅ All existing tests still pass
# ✅ Documentation updated if needed
# Run tests before commit
./gradlew :nusantara-data:allTests# Use conventional commits:
# - feat: for new features
# - fix: for bug fixes
# - docs: for documentation
# - refactor: for refactoring
# - test: for tests
# - chore: for maintenance
git add .
git commit -m "feat: add search by postal code prefix"git push origin feature/my-new-feature
# Open GitHub and create Pull Request// ✅ DO: Use clear naming
fun getProvinceById(id: String): Province?
// ❌ DON'T: Use unclear abbreviations
fun getProv(i: String): Province?
// ✅ DO: Document public APIs
/**
* Search provinces by name.
* @param query Search query (minimum 2 characters)
* @return List of matching provinces
*/
fun searchProvinces(query: String): List<ProvinceSummary>
// ✅ DO: Handle nulls safely
val province = NusantaraData.getProvinceById(id) ?: return null
// ✅ DO: Use immutable collections
val cities: List<City> = listOf(...)
// ❌ DON'T: Use mutable collections in public API
val cities: MutableList<City> = mutableListOf(...)# Run all tests
./gradlew :nusantara-data:allTests
# Run specific test class
./gradlew :nusantara-data:testDebugUnitTest --tests "*.LocationRepositoryTest"
# Run with coverage report
./gradlew :nusantara-data:koverHtmlReportIf there's a government update to location data:
python3 scripts/generate_indonesia_json.pyA: Data is sourced from Kepmendagri No 300.2.2-2138 Year 2025, which is the latest official data from the Indonesian government. We will update the library when new regional divisions occur.
A:
A: No. All data is embedded in the library, so it can be used offline.
A: Lookup by ID uses HashMap so it's O(1). Search uses linear search O(n), but it's fast enough since data is already indexed.
A: Currently focused on Android and iOS. JVM/Desktop support is in the roadmap.
A: Please create an issue on GitHub with details of the incorrect location and the correct data source. We will verify and update.
LocationDropdown - Simple dropdown for province/city/district/villageSearchableLocationDropdown - Dropdown with search capabilityLocationPickerDialog - Full-screen dialog/modal picker with searchProvinceDropdown, CityDropdown, DistrictDropdown, VillageDropdown
SearchableProvinceDropdown, SearchableCityDropdown, etc.ProvincePickerField, CityPickerField, etc.getAllCities(), getAllDistricts(), getAllVillages() methodsLocationPicker - Complete cascading location selectorProvinceSelector, CitySelector, DistrictSelector, VillageSelector
PostalCodeSearchField, ProvinceSearchField, CitySearchField
Copyright 2026 Nusantara Data Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
| Resource | Link |
|---|---|
| Maven Central | io.github.naufalprakoso.nusantara:data |
| GitHub | naufalprakoso/nusantara-data-kotlin |
| Issues | Report Bug / Request Feature |
Made with ❤️ for Indonesia 🇮🇩
A Kotlin Multiplatform (KMP) library providing complete Indonesia location data — from Province down to Postal Code.
Province → City/Regency → District → Village → Postal Code
| Data Type | Count |
|---|---|
| Provinces | 38 |
| Cities/Regencies | 514 |
| Districts (Kecamatan) | 7,265 |
| Villages (Kelurahan/Desa) | 83,202 |
| Postal Codes | 83,762 |
Data sourced from Kepmendagri No 300.2.2-2138 Year 2025 (latest official data).
| Feature | Description |
|---|---|
| 📍 Complete Data | All 38 Indonesian provinces with complete data down to postal codes |
| 🔀 Multiplatform | Supports Android and iOS in a single codebase |
| 🔒 Type-Safe | Full Kotlin data classes with proper typing |
| ⚡ Fast Lookup | O(1) lookup by ID using indexed HashMap |
| 🔍 Smart Search | Typo-tolerant search using Levenshtein distance algorithm |
| 📴 Offline | Data embedded in library, no internet required |
| 🏛️ BPS Standard | Uses official Statistics Indonesia (BPS) codes |
| 🎨 UI Components | Ready-to-use Compose Multiplatform UI components |
| 📱 Sample App | Complete sample Android app included |
Add dependency to your build.gradle.kts:
dependencies {
implementation("io.github.naufalprakoso.nusantara:data:1.0.2")
}Or in build.gradle (Groovy):
dependencies {
implementation 'io.github.naufalprakoso.nusantara:data:1.0.2'
}✅ Maven Central is enabled by default in most Gradle projects. No additional repository configuration needed!
Clone repository and add as a module:
git clone https://github.com/naufalprakoso/nusantara-data-kotlin.gitIn settings.gradle.kts:
includeBuild("path/to/nusantara-data-kotlin") {
dependencySubstitution {
substitute(module("io.github.naufalprakoso.nusantara:data")).using(project(":nusantara-data"))
}
}Add to app/build.gradle.kts:
android {
// Minimum SDK 24
defaultConfig {
minSdk = 24
}
}
dependencies {
implementation("io.github.naufalprakoso.nusantara:data:1.0.2")
}In Podfile:
pod 'NusantaraData', :git => 'https://github.com/naufalprakoso/nusantara-data-kotlin.git'.package(url: "https://github.com/naufalprakoso/nusantara-data-kotlin.git", from: "1.0.2")Call initialize() once at app startup:
import io.github.naufalprakoso.nusantara.data.NusantaraData
// In Application class (Android) or AppDelegate (iOS)
class MyApplication : Application() {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
// Initialize in background thread
applicationScope.launch {
NusantaraData.initialize()
}
}
}import io.github.naufalprakoso.nusantara.data.NusantaraData
class LocationViewModel : ViewModel() {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _provinces = MutableStateFlow<List<ProvinceSummary>>(emptyList())
val provinces: StateFlow<List<ProvinceSummary>> = _provinces.asStateFlow()
init {
viewModelScope.launch {
// Initialize data
NusantaraData.initialize()
_isInitialized.value = true
// Load provinces after initialization
_provinces.value = NusantaraData.getAllProvinces()
}
}
}
⚠️ Important:initialize()is asuspend function. Run it in a coroutine or background thread.
// ✅ Get all provinces
val provinces = NusantaraData.getAllProvinces()
// Output: [ProvinceSummary(id="11", name="Aceh", cityCount=23), ...]
// ✅ Get province by ID
val jakarta = NusantaraData.getProvinceById("31")
// Output: Province(id="31", name="DKI Jakarta", cities=[...])
// ✅ Get cities in a province
val cities = NusantaraData.getCitiesByProvinceId("31")
// Output: [CitySummary(id="3171", name="Kota Jakarta Pusat", ...), ...]
// ✅ Get districts in a city
val districts = NusantaraData.getDistrictsByCityId("3171")
// Output: [DistrictSummary(id="317101", name="Gambir", ...), ...]
// ✅ Get villages in a district
val villages = NusantaraData.getVillagesByDistrictId("317101")
// Output: [VillageSummary(id="3171012001", name="Gambir", postalCodes=["10110"]), ...]
// ✅ Search locations
val results = NusantaraData.searchProvinces("jawa")
// Output: [Jawa Barat, Jawa Tengah, Jawa Timur]
// ✅ Reverse lookup by postal code
val locations = NusantaraData.getLocationByPostalCode("10110")
// Output: [PostalCodeDetail(postalCode="10110", villageName="Gambir", ...)]// Get all provinces (38 provinces)
NusantaraData.getAllProvinces(): List<ProvinceSummary>
// Get province by BPS ID (2 digits)
NusantaraData.getProvinceById(id: String): Province?
// Get province by name (case-insensitive)
NusantaraData.getProvinceByName(name: String): Province?
// Search provinces by name (typo-tolerant)
NusantaraData.searchProvinces(query: String, tolerance: Int? = null): List<ProvinceSummary>// Get ALL cities in Indonesia (514 cities)
NusantaraData.getAllCities(): List<CitySummary>
// Get all cities in a province
NusantaraData.getCitiesByProvinceId(provinceId: String): List<CitySummary>
// Get city by BPS ID (4 digits)
NusantaraData.getCityById(id: String): City?
// Get city by name (optional: filter by province)
NusantaraData.getCityByName(name: String, provinceId: String? = null): City?
// Search cities by name (typo-tolerant, optional: filter by province)
NusantaraData.searchCities(query: String, provinceId: String? = null, tolerance: Int? = null): List<CitySummary>// Get ALL districts in Indonesia (7,265 districts)
NusantaraData.getAllDistricts(): List<DistrictSummary>
// Get all districts in a city
NusantaraData.getDistrictsByCityId(cityId: String): List<DistrictSummary>
// Get district by BPS ID (6 digits)
NusantaraData.getDistrictById(id: String): District?
// Get district by name (optional: filter by city)
NusantaraData.getDistrictByName(name: String, cityId: String? = null): District?
// Search districts by name (typo-tolerant, optional: filter by city)
NusantaraData.searchDistricts(query: String, cityId: String? = null, tolerance: Int? = null): List<DistrictSummary>// Get ALL villages in Indonesia (83,202 villages)
NusantaraData.getAllVillages(): List<VillageSummary>
// Get all villages in a district
NusantaraData.getVillagesByDistrictId(districtId: String): List<VillageSummary>
// Get village by BPS ID (10 digits)
NusantaraData.getVillageById(id: String): Village?
// Get village by name (optional: filter by district)
NusantaraData.getVillageByName(name: String, districtId: String? = null): Village?
// Search villages by name (typo-tolerant, districtId required for performance)
NusantaraData.searchVillages(query: String, districtId: String, tolerance: Int? = null): List<VillageSummary>// Get postal codes for a village
NusantaraData.getPostalCodesByVillageId(villageId: String): List<String>
// Get all postal codes in a district
NusantaraData.getPostalCodesByDistrictId(districtId: String): List<String>
// Reverse lookup: get full location by postal code
NusantaraData.getLocationByPostalCode(postalCode: String): List<PostalCodeDetail>// Get library version
NusantaraData.getVersion(): String // "1.0.2"
// Get data last updated date
NusantaraData.getLastUpdated(): String // "2026-01-29"
// Get data statistics
NusantaraData.getStatistics(): DataStatisticsdata class Province(
val id: String, // BPS code (2 digits), e.g., "31"
val name: String, // e.g., "DKI Jakarta"
val cities: List<City>
)
data class ProvinceSummary(
val id: String,
val name: String,
val cityCount: Int // Number of cities/regencies
)data class City(
val id: String, // BPS code (4 digits), e.g., "3171"
val provinceId: String,
val name: String, // e.g., "Kota Jakarta Pusat"
val type: CityType, // KOTA or KABUPATEN
val districts: List<District>
)
data class CitySummary(
val id: String,
val provinceId: String,
val name: String,
val type: CityType,
val districtCount: Int
)
enum class CityType {
KOTA, // City
KABUPATEN // Regency
}data class District(
val id: String, // BPS code (6 digits), e.g., "317101"
val cityId: String,
val name: String, // e.g., "Gambir"
val villages: List<Village>
)
data class DistrictSummary(
val id: String,
val cityId: String,
val name: String,
val villageCount: Int
)data class Village(
val id: String, // BPS code (10 digits), e.g., "3171012001"
val districtId: String,
val name: String, // e.g., "Kelurahan Gambir"
val postalCodes: List<String>
)
data class VillageSummary(
val id: String,
val districtId: String,
val name: String,
val postalCodes: List<String>
)data class PostalCodeDetail(
val postalCode: String, // e.g., "10110"
val villageId: String,
val villageName: String,
val districtId: String,
val districtName: String,
val cityId: String,
val cityName: String,
val cityType: CityType,
val provinceId: String,
val provinceName: String
) {
// Get formatted full address
fun getFullAddress(): String
// Output: "Gambir, Gambir, Kota Jakarta Pusat, DKI Jakarta 10110"
}This library uses official BPS (Statistics Indonesia) codes:
| Level | Digits | Format | Example | Description |
|---|---|---|---|---|
| Province | 2 | XX |
31 |
DKI Jakarta |
| City/Regency | 4 | XXXX |
3171 |
Kota Jakarta Pusat |
| District | 6 | XXXXXX |
317101 |
Gambir |
| Village | 10 | XXXXXXXXXX |
3171012001 |
Kelurahan Gambir |
| Postal Code | 5 | XXXXX |
10110 |
Postal Code |
💡 Tips: Child level IDs always start with parent level IDs. Example: City
3171starts with province31.
Implementing an address form with cascading dropdowns:
class AddressFormViewModel : ViewModel() {
// State
var selectedProvince by mutableStateOf<ProvinceSummary?>(null)
var selectedCity by mutableStateOf<CitySummary?>(null)
var selectedDistrict by mutableStateOf<DistrictSummary?>(null)
var selectedVillage by mutableStateOf<VillageSummary?>(null)
var selectedPostalCode by mutableStateOf<String?>(null)
// Options
val provinces = NusantaraData.getAllProvinces()
var cities by mutableStateOf<List<CitySummary>>(emptyList())
var districts by mutableStateOf<List<DistrictSummary>>(emptyList())
var villages by mutableStateOf<List<VillageSummary>>(emptyList())
var postalCodes by mutableStateOf<List<String>>(emptyList())
fun onProvinceSelected(province: ProvinceSummary) {
selectedProvince = province
selectedCity = null
selectedDistrict = null
selectedVillage = null
selectedPostalCode = null
// Load cities for selected province
cities = NusantaraData.getCitiesByProvinceId(province.id)
districts = emptyList()
villages = emptyList()
postalCodes = emptyList()
}
fun onCitySelected(city: CitySummary) {
selectedCity = city
selectedDistrict = null
selectedVillage = null
selectedPostalCode = null
// Load districts for selected city
districts = NusantaraData.getDistrictsByCityId(city.id)
villages = emptyList()
postalCodes = emptyList()
}
fun onDistrictSelected(district: DistrictSummary) {
selectedDistrict = district
selectedVillage = null
selectedPostalCode = null
// Load villages for selected district
villages = NusantaraData.getVillagesByDistrictId(district.id)
postalCodes = emptyList()
}
fun onVillageSelected(village: VillageSummary) {
selectedVillage = village
// Set postal codes (usually 1, sometimes multiple)
postalCodes = village.postalCodes
selectedPostalCode = postalCodes.firstOrNull()
}
}Implementing search with debounce:
class SearchViewModel : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchResults: StateFlow<List<CitySummary>> = _searchQuery
.debounce(300) // Wait 300ms after user stops typing
.map { query ->
if (query.length >= 2) {
NusantaraData.searchCities(query)
} else {
emptyList()
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
}All search functions are typo-tolerant by default:
// User types "Jakrta" instead of "Jakarta" - still works!
val results = NusantaraData.searchProvinces("Jakrta")
// Results: [ProvinceSummary(name="DKI Jakarta", ...)]
// User types "Bandug" instead of "Bandung"
val cities = NusantaraData.searchCities("Bandug")
// Results: [CitySummary(name="Kota Bandung", ...), CitySummary(name="Kabupaten Bandung", ...)]
// Common Indonesian typos are handled:
// "Jogjakarta" → matches "Yogyakarta"
// "Surabya" → matches "Surabaya"
// "Denpassar" → matches "Denpasar"
// "Makasar" → matches "Makassar"
// Use custom tolerance for stricter/looser matching
val strictResults = NusantaraData.searchCities("Bandug", tolerance = 1)
val looseResults = NusantaraData.searchCities("Bndung", tolerance = 3)
// Filter by parent region for better performance
val jakartaDistricts = NusantaraData.searchDistricts(
query = "Gambr",
cityId = "3171" // Jakarta Pusat only
)Get full address from postal code:
fun getAddressFromPostalCode(postalCode: String): String? {
val locations = NusantaraData.getLocationByPostalCode(postalCode)
return locations.firstOrNull()?.let { location ->
buildString {
appendLine("Village: ${location.villageName}")
appendLine("District: ${location.districtName}")
appendLine("City: ${location.cityName}")
appendLine("Province: ${location.provinceName}")
appendLine("Postal Code: ${location.postalCode}")
}
}
}
// Usage
val address = getAddressFromPostalCode("10110")
// Output:
// Village: Gambir
// District: Gambir
// City: Kota Jakarta Pusat
// Province: DKI Jakarta
// Postal Code: 10110The library includes ready-to-use Compose Multiplatform UI components:
import io.github.naufalprakoso.nusantara.data.ui.*
@Composable
fun AddressForm() {
var selection by rememberLocationPickerState()
// Complete location picker with cascading dropdowns
LocationPicker(
selection = selection,
onSelectionChanged = { selection = it },
labels = LocationPickerLabels.Indonesian // or use English default
)
// Show selected address
if (selection.isComplete) {
Text("Address: ${selection.toAddressString()}")
Text("Postal Code: ${selection.postalCodes.firstOrNull()}")
}
}You can also use individual selectors:
// Province selector
ProvinceSelector(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> /* handle selection */ }
)
// City selector (requires provinceId)
CitySelector(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> /* handle selection */ }
)
// District selector (requires cityId)
DistrictSelector(
cityId = selectedCity?.id,
selectedDistrict = selectedDistrict,
onDistrictSelected = { district -> /* handle selection */ }
)
// Village selector (requires districtId)
VillageSelector(
districtId = selectedDistrict?.id,
selectedVillage = selectedVillage,
onVillageSelected = { village -> /* handle selection */ }
)Use simple dropdown components for basic location selection:
// Province dropdown
ProvinceDropdown(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> selectedProvince = province },
label = "Provinsi",
placeholder = "Pilih Provinsi"
)
// City dropdown (requires provinceId)
CityDropdown(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> selectedCity = city }
)
// District and Village dropdowns work similarly
DistrictDropdown(cityId = selectedCity?.id, ...)
VillageDropdown(districtId = selectedDistrict?.id, ...)Dropdowns with search capability for easier selection:
// Searchable province dropdown
SearchableProvinceDropdown(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> selectedProvince = province },
label = "Provinsi",
placeholder = "Ketik untuk mencari..."
)
// Searchable city dropdown
SearchableCityDropdown(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> selectedCity = city }
)
// District and Village also available
SearchableDistrictDropdown(cityId = selectedCity?.id, ...)
SearchableVillageDropdown(districtId = selectedDistrict?.id, ...)Full-screen dialog for location selection with search:
// Province picker with dialog
ProvincePickerField(
selectedProvince = selectedProvince,
onProvinceSelected = { province -> selectedProvince = province },
label = "Provinsi",
dialogTitle = "Pilih Provinsi"
)
// City picker with dialog
CityPickerField(
provinceId = selectedProvince?.id,
selectedCity = selectedCity,
onCitySelected = { city -> selectedCity = city },
dialogTitle = "Pilih Kota/Kabupaten"
)
// Or use LocationPickerDialog directly for full customization
var showDialog by remember { mutableStateOf(false) }
LocationPickerDialog(
locationType = LocationType.PROVINCE,
isVisible = showDialog,
onDismiss = { showDialog = false },
onItemSelected = { item -> selectedProvince = item as ProvinceSummary },
config = LocationPickerDialogConfig(
title = "Pilih Provinsi",
showSearchBar = true,
showConfirmButton = false // Select immediately on click
)
)All UI components are fully customizable:
// Custom dropdown configuration
LocationDropdown(
locationType = LocationType.PROVINCE,
selectedValue = selectedProvince?.name ?: "",
onValueSelected = { /* handle */ },
config = LocationDropdownConfig(
label = "Province",
placeholder = "Select...",
enabled = true,
isError = hasError,
errorMessage = "Required field",
labelStyle = MaterialTheme.typography.labelLarge,
containerColor = Color.White
),
// Custom item rendering
dropdownItem = { item, onClick ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp)
) {
Text((item as ProvinceSummary).name)
Spacer(Modifier.weight(1f))
Text("${item.cityCount} cities")
}
}
)For large datasets, use search fields with autocomplete:
// Search by postal code
PostalCodeSearchField(
onLocationSelected = { detail ->
println("Found: ${detail.villageName}, ${detail.provinceName}")
}
)
// Search provinces
ProvinceSearchField(
onProvinceSelected = { province ->
println("Selected: ${province.name}")
}
)
// Search cities (optionally filtered by province)
CitySearchField(
provinceId = selectedProvince?.id, // optional filter
onCitySelected = { city ->
println("Selected: ${city.name}")
}
)A complete sample Android app is included in the sample/ module, demonstrating:
# Build and install sample app
./gradlew :sample:installDebug
# Or build APK
./gradlew :sample:assembleDebug
# APK at: sample/build/outputs/apk/debug/sample-debug.apk# Clone repository
git clone https://github.com/naufalprakoso/nusantara-data-kotlin.git
cd nusantara-data-kotlin
# Build all targets
./gradlew :nusantara-data:build
# Run tests
./gradlew :nusantara-data:allTests
# Build Android AAR
./gradlew :nusantara-data:assembleRelease
# Build iOS Framework
./gradlew :nusantara-data:linkDebugFrameworkIosSimulatorArm64nusantara-data-kotlin/
├── build.gradle.kts # Root build config
├── settings.gradle.kts # Project settings
├── gradle.properties # Gradle properties
├── gradle/
│ └── libs.versions.toml # Version catalog
├── nusantara-data/ # Main library module
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/ # Shared code (Kotlin)
│ │ ├── kotlin/io/github/naufalprakoso/nusantara/data/
│ │ │ ├── NusantaraData.kt # Main entry point
│ │ │ ├── model/ # Data classes
│ │ │ ├── repository/ # Repository pattern
│ │ │ ├── datasource/ # Data source
│ │ │ └── ui/ # Compose UI components
│ │ │ ├── LocationPicker.kt # Complete location picker
│ │ │ ├── LocationSelectors.kt # Individual selectors
│ │ │ └── LocationSearchField.kt # Search components
│ │ └── composeResources/files/
│ │ └── indonesia_locations.json # Location data (20MB)
│ ├── commonTest/ # Shared tests
│ ├── androidMain/ # Android-specific code
│ └── iosMain/ # iOS-specific code
├── sample/ # Sample Android app
│ ├── build.gradle.kts
│ └── src/main/kotlin/io/github/naufalprakoso/nusantara/data/sample/
│ └── MainActivity.kt # Demo app
└── README.md
We greatly appreciate contributions from the community! Here's how to contribute:
| Type | Description |
|---|---|
| 🐛 Bug Reports | Report bugs you find |
| 💡 Feature Requests | Suggest new features |
| 📖 Documentation | Fix or add documentation |
| 🔧 Code | Submit pull requests for fixes or features |
| 🌐 Data Updates | Update location data when changes occur |
# Fork repository via GitHub UI, then:
git clone https://github.com/YOUR_USERNAME/nusantara-data-kotlin.git
cd nusantara-data-kotlin# Use naming convention:
# - feature/feature-name
# - fix/bug-name
# - docs/description
git checkout -b feature/my-new-feature# Ensure your code:
# ✅ Follows Kotlin coding conventions
# ✅ Includes unit tests for new features
# ✅ All existing tests still pass
# ✅ Documentation updated if needed
# Run tests before commit
./gradlew :nusantara-data:allTests# Use conventional commits:
# - feat: for new features
# - fix: for bug fixes
# - docs: for documentation
# - refactor: for refactoring
# - test: for tests
# - chore: for maintenance
git add .
git commit -m "feat: add search by postal code prefix"git push origin feature/my-new-feature
# Open GitHub and create Pull Request// ✅ DO: Use clear naming
fun getProvinceById(id: String): Province?
// ❌ DON'T: Use unclear abbreviations
fun getProv(i: String): Province?
// ✅ DO: Document public APIs
/**
* Search provinces by name.
* @param query Search query (minimum 2 characters)
* @return List of matching provinces
*/
fun searchProvinces(query: String): List<ProvinceSummary>
// ✅ DO: Handle nulls safely
val province = NusantaraData.getProvinceById(id) ?: return null
// ✅ DO: Use immutable collections
val cities: List<City> = listOf(...)
// ❌ DON'T: Use mutable collections in public API
val cities: MutableList<City> = mutableListOf(...)# Run all tests
./gradlew :nusantara-data:allTests
# Run specific test class
./gradlew :nusantara-data:testDebugUnitTest --tests "*.LocationRepositoryTest"
# Run with coverage report
./gradlew :nusantara-data:koverHtmlReportIf there's a government update to location data:
python3 scripts/generate_indonesia_json.pyA: Data is sourced from Kepmendagri No 300.2.2-2138 Year 2025, which is the latest official data from the Indonesian government. We will update the library when new regional divisions occur.
A:
A: No. All data is embedded in the library, so it can be used offline.
A: Lookup by ID uses HashMap so it's O(1). Search uses linear search O(n), but it's fast enough since data is already indexed.
A: Currently focused on Android and iOS. JVM/Desktop support is in the roadmap.
A: Please create an issue on GitHub with details of the incorrect location and the correct data source. We will verify and update.
LocationDropdown - Simple dropdown for province/city/district/villageSearchableLocationDropdown - Dropdown with search capabilityLocationPickerDialog - Full-screen dialog/modal picker with searchProvinceDropdown, CityDropdown, DistrictDropdown, VillageDropdown
SearchableProvinceDropdown, SearchableCityDropdown, etc.ProvincePickerField, CityPickerField, etc.getAllCities(), getAllDistricts(), getAllVillages() methodsLocationPicker - Complete cascading location selectorProvinceSelector, CitySelector, DistrictSelector, VillageSelector
PostalCodeSearchField, ProvinceSearchField, CitySearchField
Copyright 2026 Nusantara Data Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
| Resource | Link |
|---|---|
| Maven Central | io.github.naufalprakoso.nusantara:data |
| GitHub | naufalprakoso/nusantara-data-kotlin |
| Issues | Report Bug / Request Feature |
Made with ❤️ for Indonesia 🇮🇩