Flutter to Native Code Migration: Supabase | 4/5

Siro DavesSiro Daves
8 min read

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:

  1. 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
}
  1. 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:

  1. Add Dependencies via the Swift Package Manager by going to:
    File → Add Packages

  1. Add Supabase by entering:

     https://github.com/supabase/supabase-swift.git
    

    and select the latest stable version.

Access Credentials

  1. Go to Supabase and signup or sign in to your account.

  2. After creating an account you'll be prompted to create an organization.

  3. 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

  4. To create the fruits table enter the following sql query the query editor and click on Run

     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;
    
  5. Create an RLS policy for the table so that it can be accessed in public.

  6. Now that you have fully setup your supabase backend we need the api url and key to continue.

  7. On the top navigation menu click on Connect

  8. On the pop dialog that appear click on App Frameworks


In my flutter app I have 2 ways of accessing the access credentials.

  1. Using keys.json + String.fromEnvironment.

    • Credentials are passed at build time using --dart-define flags:
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.

  1. Using .env + flutter_dotenv package.

    • Store credentials in a .env file:
    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

  1. 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=
    
  2. 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

  1. Create a Secrets.plist (not checked into version control)

  2. 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>
    
  3. 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

0
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