Flutter to Native Code Migration: Supabase | 4/5


This is a continuation of my articles on migrating from Flutter to Native code and on this particle article I focus solely on Supabase intergration.
Project Setup
a) Android
Start by creating a new Android project in Android Studio. Once your project is initialized, follow these steps to configure Supabase:
- In your
app/build.gradle.kts
, ensure you have:
dependencies {
// Networking - Supabase & Ktor
implementation(platform(libs.jan.tennert.supabase.bom)) // Supabase BOM
implementation(libs.jan.tennert.supabase.postgrest) // Supabase PostgREST support
implementation(libs.ktor.client.android) // Ktor HTTP client for Android
implementation(libs.kotlinx.serialization.json) // Kotlin Serialization for Android // Compose JUnit4 test support
}
- Now sync your project in readiness for the next setup of your project
b) iOS
Start by creating a new iOS project in Xcode using Swift + SwiftUI. Once your project is initialized, follow these steps to configure Supabase:
- Add Dependencies via the Swift Package Manager by going to:
File → Add Packages
Add Supabase by entering:
https://github.com/supabase/supabase-swift.git
and select the latest stable version.
Access Credentials
Go to Supabase and signup or sign in to your account.
After creating an account you'll be prompted to create an organization.
After creating an organization you'll be prompted to create a project. You can edit the project name, add a database password and change the database region closest to you and click on
Create new project
To create the
fruits
table enter the following sql query the query editor and click onRun
create table public.fruits ( id bigint generated by default as identity not null, title text null, description character varying null, color text null, "createdAt" timestamp with time zone not null default now(), "updatedAt" timestamp with time zone null, constraint fruits_pkey primary key (id) ) TABLESPACE pg_default;
Create an RLS policy for the table so that it can be accessed in public.
Now that you have fully setup your supabase backend we need the api url and key to continue.
On the top navigation menu click on
Connect
On the pop dialog that appear click on
App Frameworks
In my flutter app I have 2 ways of accessing the access credentials.
Using
keys.json
+String.fromEnvironment
.- Credentials are passed at build time using
--dart-define
flags:
- Credentials are passed at build time using
flutter run --dart-define=SUPABASE_URL=https://xyz.supabase.co --dart-define=SUPABASE_ANON_KEY=abc123
- Load and access in
main.dart
:
const supabaseUrl = String.fromEnvironment("SUPABASE_URL");
const supabaseAnonKey = String.fromEnvironment("SUPABASE_ANON_KEY");
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
Advantages:
Secure for different build environments (dev, staging, prod) without changing files.
Credentials do not live in the codebase or files that could leak into version control.
Works seamlessly with CI/CD pipelines and flavor setups.
Tree-shakable since unused constants are removed in production builds.
Disadvantages:
Requires manual setup of
--dart-define
flags or using a tool like Flutter Flavorizr for management.Cannot easily check or view values during development without printing them.
If you forget to pass the flags, the values will be
null
or empty at runtime.
Using
.env
+flutter_dotenv
package.- Store credentials in a
.env
file:
- Store credentials in a
SUPABASE_URL=https://xyz.supabase.co
SUPABASE_ANON_KEY=abc123
- Load and access in
main.dart
:
await dotenv.load(fileName: ".env");
final supabaseUrl = dotenv.env['SUPABASE_URL'];
final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'];
await Supabase.initialize(url: supabaseUrl!, anonKey: supabaseAnonKey!);
Advantages:
Simple and developer-friendly during local development.
Easy to switch environments by changing the
.env
file or using.env.staging
,.env.production
, etc.No need to pass build flags manually during local testing.
Easy to view and edit credentials when debugging.
Disadvantages:
Does not work out of the box for web builds (requires workarounds).
Credentials still live in local files, requiring careful management across environments.
Harder to handle securely in CI/CD without additional scripts for injecting environment files.
Now away from how I access my credentials in my flutter app here is how to on Android and iOS
a) Android
Copy the NEXT_PUBLIC_SUPABASE_URL & NEXT_PUBLIC_SUPABASE_ANON_KEY into your
local.properties
file in your and as below:SUPABASE_URL= SUPABASE_ANON_KEY=
In your
app/build.gradle.kts
add this:
val configProperties = Properties()
val configFile = rootProject.file("gradle/config/config.properties")
if (configFile.exists()) {
configProperties.load(configFile.inputStream())
}
and in your defaultConfig inside android add the properties
android {
defaultConfig {
val properties = Properties()
properties.load(project.rootProject.file("local.properties").inputStream())
buildConfigField("String", "SupabaseUrl", "\"${properties.getProperty("SUPABASE_URL")}\"")
buildConfigField("String", "SupabaseKey", "\"${properties.getProperty("SUPABASE_ANON_KEY")}\"")
}
}
b) iOS
Create a
Secrets.plist
(not checked into version control)Add your access credentials as below
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>SUPABASE_URL</key> <string></string> <key>SUPABASE_ANON_KEY</key> <string></string> </dict> </plist>
Load these values into your app at runtime using:
struct SupabaseSecrets { static let url: String = { guard let path = Bundle.main.path(forResource: "Secrets", ofType: "plist"), let dict = NSDictionary(contentsOfFile: path), let value = dict["SUPABASE_URL"] as? String else { fatalError("Missing SUPABASE_URL in Secrets.plist") } return value }() static let anonKey: String = { guard let path = Bundle.main.path(forResource: "Secrets", ofType: "plist"), let dict = NSDictionary(contentsOfFile: path), let value = dict["SUPABASE_ANON_KEY"] as? String else { fatalError("Missing SUPABASE_ANON_KEY in Secrets.plist") } return value }() }
Using the Supabase Client
In Flutter, the Supabase client comes built into the official supabase_flutter
package, so you don’t need to create the client manually. Instead, you initialize it directly in the main.dart as I had already shared earlier:
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
Once initialized, you can access the client globally via:
final supabase = Supabase.instance.client;
However in android and ios you need to create a client manually as follows:
a) Android (Kotlin)
On Android, using the Kotlin Supabase SDK, you manually create and provide the SupabaseClient
using dependency injection (Hilt or Koin).
@Module
@InstallIn(SingletonComponent::class)
object SupabaseModule {
@Provides
@Singleton
fun provideSupabaseClient(): SupabaseClient {
return createSupabaseClient(
supabaseUrl = BuildConfig.SupabaseUrl,
supabaseKey = BuildConfig.SupabaseKey
) {
install(Postgrest)
}
}
@Provides
@Singleton
fun provideSupabaseDatabase(client: SupabaseClient): Postgrest {
return client.postgrest
}
}
b) iOS (Swift)
On iOS, you create the SupabaseClient
explicitly and manage its lifecycle within a service class:
final class SupabaseService: SupabaseServiceProtocol {
let client: SupabaseClient
init() {
let urlString = SupabaseSecrets.url
let anonKey = SupabaseSecrets.anonKey
guard let url = URL(string: urlString), !anonKey.isEmpty else {
fatalError("Invalid Supabase URL or anon key. Check Secrets.plist.")
}
self.client = SupabaseClient(
supabaseURL: url,
supabaseKey: anonKey
)
}
}
Invoking Supabase in your Repository
After setting up the Supabase client, the next step is using it to invoke your Supabase backend consistently across platforms. In Flutter, I just invoke the table directly:
final result = await Supabase.instance.client.from('fruits').select();
Wrapped in a repository:
class FruitRepository {
final SupabaseClient supabase;
FruitRepository(this.supabase);
Future<List<Fruit>> fetchFruits() async {
logger('Now fetching fruits');
try {
final result = await supabase.from('fruits').select();
final fruits = (result as List)
.map((item) => Fruit.fromJson(item))
.toList();
logger('${fruits.length} fruits fetched');
return fruits;
} catch (e) {
logger('Unable to fetch fruits: $e');
return [];
}
}
}
From this Direct, readable API call I can get a list of strongly typed Fruit
objects for the UI.
a) Android (Kotlin)
Using the Supabase Kotlin SDK, I invoke my table with:
val result = supabase["fruits"].select().decodeList<FruitDto>()
Wrapped in a repository with dependency injection:
@Singleton
class FruitRepository @Inject constructor(
private val supabase: Postgrest,
) {
fun fetchFruits(): Flow<List<Fruit>> = flow {
try {
Log.d("TAG", "Now fetching fruits")
val result = supabase["fruits"].select().decodeList<FruitDto>()
val fruits = result.map { MapDtoToEntity.mapToEntity(it) }
Log.d("TAG", "Fetched ${fruits.size} fruits")
emit(fruits)
} catch (e: Exception) {
Log.d("TAG", "Unable to fetch fruits: ${e.message}")
}
}
}
This request Uses Kotlin Flows for reactive streams after which it maps DTOs (Data Transfer Objects) to domain entities for clean separation.
b) iOS (Swift)
Using the Supabase Swift SDK, I invoke my table with:
let results: [FruitDTO] = try await supabase.client.from("fruits").select().execute().value
Wrapped in a repository:
class FruitRepository: FruitRepositoryProtocol {
private let supabase: SupabaseServiceProtocol
init(supabase: SupabaseServiceProtocol) {
self.supabase = supabase
}
func fetchFruits() async throws -> [Fruit] {
do {
let results: [FruitDTO] = try await supabase.client.from("fruits").select().execute().value
let fruits = results.map { MapDtoToEntity.mapToEntity($0) }
print("✅ Fruits fetched: \(fruits.count)")
return fruits
} catch {
print("❌ Failed to fetch fruits: \(error.localizedDescription)")
throw error
}
}
}
This request uses Swift's async/await for clean asynchronous code after which it maps DTOs to domain entities, maintaining clear architecture.
Mapping DTOs to Domain Entities
Migrating my Flutter apps like SwahiLib and SongLib to native Android and iOS taught me the value of clean, explicit data handling. In Flutter, dynamic fromJson
decoding was enough, but on Android and iOS, explicit DTO-to-entity mapping became essential for type safety, testability, and clean architecture, especially when syncing with Supabase or Core Data. This step ensured that backend changes or differences in local storage structures would not tightly couple or break my app’s business logic and UI layers.
Using DTOs to mirror backend data and mapping them into clean domain entities makes your code safer, clearer, and easier to maintain. It allows you to add computed properties or handle nulls gracefully while keeping your presentation and business logic clean. Whether working with Kotlin’s data classes and mappers or Swift’s Codable structs and transformation layers, this explicit mapping ensures your app can scale confidently while maintaining flexibility across Flutter, Android, and iOS environments.
Please take time to read more about how I did the mapping in my previous article: Flutter to Native Migration: Data Handling and Mapping
In the next article, I will talk about the highlights of this migration of Flutter to Native code
Subscribe to my newsletter
Read articles from Siro Daves directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Siro Daves
Siro Daves
Software engineer and a Technical Writer, Best at Flutter mobile app development, full stack development with Mern. Other areas are like Android, Kotlin, .Net and Qt