Skip to content

Instantly share code, notes, and snippets.

@AndroidPoet
Last active June 7, 2026 17:07
Show Gist options
  • Select an option

  • Save AndroidPoet/85471a603d179492726f694928e9962d to your computer and use it in GitHub Desktop.

Select an option

Save AndroidPoet/85471a603d179492726f694928e9962d to your computer and use it in GitHub Desktop.

I Built a Leaner Supabase SDK for Kotlin Multiplatform. Here’s Why It’s Architecturally Better

Android Poet

5 min read

How trading framework-heavy SDK design for explicit results, typed IDs, direct composition, and Kotlin-first APIs made Supabase feel cleaner in Kotlin Multiplatform.

When I first started building Kotlin Multiplatform apps with Supabase, I reached for the official Kotlin SDK.

It worked. It had the things I needed: Auth, PostgREST, Storage, Realtime, Edge Functions.

But every time I used it, something felt off.

The exceptions. The string IDs. The plugin-style setup. The framework-heavy dependency model. The feeling that every normal API failure had to be handled like a surprise.

So I asked myself:

What would a Supabase SDK look like if we built it the way Kotlin was meant to be used?

That became supabase-kmp: a Kotlin Multiplatform SDK built around explicit error handling, value class IDs, coroutine-first APIs, typed serialization, and direct module composition.

The goal was never to copy JavaScript syntax into Kotlin.

The goal was to make Supabase feel native in Kotlin.

The Problem with the Official SDK

1. Exception-Based Error Handling

Here’s the kind of code you often end up writing:

try {
    val session = auth.signInWithEmail(
        email = "user@example.com",
        password = "password",
    )
} catch (e: RestException) {
    // Handle API error
} catch (e: HttpRequestTimeoutException) {
    // Handle timeout
} catch (e: Exception) {
    // Handle everything else
}

This isn’t exceptional.

It’s exhausting.

A failed login is not a surprise. RLS blocking a request is not a surprise. A missing row is not a surprise. These are normal API outcomes.

But the function signature does not tell you that. You need documentation, try-catch blocks, and memory of what each call might throw.

2. String IDs Without Type Safety

suspend fun getUser(id: String)
suspend fun getPost(id: String)

No compile-time protection.

Mix up a user ID with a post ID, and you find out at runtime, not compile time.

Kotlin gives us better tools than that.

3. Framework-Style Architecture

Plugin-based SDKs can be convenient, but they also shape your app around the SDK.

You create one central client. You install modules into it. The SDK becomes a small framework.

That is not always wrong.

But for my apps, I wanted something simpler:

  • Create a client.
  • Add the modules I need.
  • Inject or pass them like normal Kotlin objects.
  • Own the lifecycle myself.

No magic plugin registry. No hidden framework shape.

My Approach: Building It Differently

I set out to solve these problems with three architectural decisions:

  1. Result Monad instead of exception-first APIs.
  2. Value Class IDs for compile-time type safety.
  3. Modular Library Design with direct composition.

Here’s how it looks today in supabase-kmp v0.3.2.

1. Explicit Error Handling with Result Monad

Instead of throwing exceptions for normal API failures, operations return SupabaseResult<T>:

when (val result = auth.signInWithEmail("user@example.com", "password")) {
    is SupabaseResult.Success -> {
        val session = result.value
    }

    is SupabaseResult.Failure -> {
        val error = result.error
    }
}

The return type tells you the truth.

No surprise exception soup. No guessing. No digging through docs just to know whether a call can fail.

You can also write fluent handlers:

auth.signUpWithEmail(email, password)
    .onSuccess { session -> println("Welcome!") }
    .onConflict { error -> showToast("User already exists") }
    .onUnauthorized { error -> showToast("Invalid credentials") }
    .onRateLimited { showToast("Slow down") }

That is the API shape I wanted:

Explicit when I need control.

Convenient when I want app-level behavior.

2. Compile-Time Type Safety with Value Classes

Every important ID can be represented as a value class.

@JvmInline
value class UserId(val value: String)

@JvmInline
value class BucketId(val value: String)

@JvmInline
value class ChannelId(val value: String)

Now this kind of mistake can be caught by the compiler:

val bucketId = BucketId("avatars")

getUser(bucketId) // Compile error if getUser expects UserId

That is the whole point.

The compiler should protect your domain model. IDs should not all become anonymous strings.

And because these are value classes, we keep the type safety without adding unnecessary wrapper-object overhead.

For Kotlin Multiplatform, that matters. Especially on Kotlin/Native, unnecessary allocation is not free.

3. Modular Architecture with Direct Composition

The SDK is published as separate Maven Central modules:

[versions]
supabase-kmp = "0.3.2"

[libraries]
supabase-core = { module = "io.github.androidpoet:supabase-core", version.ref = "supabase-kmp" }
supabase-client = { module = "io.github.androidpoet:supabase-client", version.ref = "supabase-kmp" }
supabase-auth = { module = "io.github.androidpoet:supabase-auth", version.ref = "supabase-kmp" }
supabase-auth-admin = { module = "io.github.androidpoet:supabase-auth-admin", version.ref = "supabase-kmp" }
supabase-database = { module = "io.github.androidpoet:supabase-database", version.ref = "supabase-kmp" }
supabase-storage = { module = "io.github.androidpoet:supabase-storage", version.ref = "supabase-kmp" }
supabase-realtime = { module = "io.github.androidpoet:supabase-realtime", version.ref = "supabase-kmp" }
supabase-functions = { module = "io.github.androidpoet:supabase-functions", version.ref = "supabase-kmp" }

Create the shared client once:

val client = Supabase.create(
    projectUrl = "https://your-project.supabase.co",
    apiKey = "your-anon-key",
) {
    logging = true
}

Then compose only what you need:

val auth = createAuthClient(client)
val database = createDatabaseClient(client)
val storage = createStorageClient(client)
val realtime = createRealtimeClient(client)
val functions = createFunctionsClient(client)

That’s it.

No plugin install block. No framework ceremony. No forced DI container.

If you use Koin, Hilt, Kodein, or manual constructors, the SDK does not care. These are normal Kotlin objects.

What We Actually Offer Today

The old version of this article talked about v0.1.0.

That is outdated now.

supabase-kmp v0.3.2 covers much more of Supabase.

Auth

Auth now supports:

  • Email and phone sign-up/sign-in.
  • Anonymous sign-in.
  • OTP, token-hash verification, resend, and password reset.
  • OAuth URL generation.
  • PKCE code exchange.
  • SSO URL retrieval.
  • ID token sign-in.
  • Web3 sign-in with caller-provided signed messages.
  • Identity link/unlink.
  • User update, sign-out, refresh, reauthentication, and user identity APIs.
  • MFA enroll, challenge, verify, unenroll, list factors, and assurance level.
  • Passkey registration/authentication start and verify endpoints.
  • Passkey list, update, and delete.
  • Session persistence, auto-refresh, and StateFlow session state.

Important note:

Passkeys and Web3 are real API support, not fake methods. But Android/iOS passkey prompts and wallet prompts are platform responsibilities. The SDK exposes the Supabase API boundary. Your app still owns the native credential ceremony.

That is the right split.

Auth Admin

There is now a separate supabase-auth-admin module for service-role APIs:

  • Admin user CRUD.
  • Invite user.
  • Generate auth links.
  • Admin sign-out.
  • Admin MFA factor management.
  • OAuth client admin APIs.
  • Custom provider admin APIs.
  • Passkey admin list/delete.

This is not for anon-key mobile clients. It belongs in trusted server/admin contexts.

Database / PostgREST

Database supports:

  • Select, insert, update, delete.
  • RPC.
  • Typed Kotlin serialization helpers.
  • Single and maybe-single helpers.
  • CSV and HEAD variants.
  • Schema support.
  • Per-call headers.
  • Explain support.
  • Rollback, max affected, strip nulls.
  • Scoped retry for transient read failures.
  • Advanced PostgREST filters.

Example:

@Serializable
data class Todo(
    val id: String,
    val title: String,
    val done: Boolean,
)

val todos = database.selectTyped<Todo>(
    table = "todos",
) {
    eq("done", "false")
    order("created_at", ascending = false)
    limit(25)
}

The filter DSL includes eq, neq, gt, gte, lt, lte, like, ilike, in, is, contains, containedBy, overlaps, range filters, not, or, and, textSearch, and raw filter.

Storage

Storage supports:

  • Bucket list/get/create/update/delete/empty.
  • Upload, update, download, list, info, exists, move, copy, remove.
  • Public URLs.
  • Authenticated URLs.
  • Signed download URLs.
  • Batch signed URLs.
  • Signed upload URLs.
  • Upload-to-signed-URL.
  • Image/render transform URLs.
  • listV2.

And newer Supabase Storage surfaces are covered too:

  • Analytics buckets.
  • Vector buckets.
  • Vector indexes.
  • Vector CRUD.
  • Vector query.
  • Analytics Catalog / Iceberg REST catalog helpers.
  • Typed Iceberg catalog models plus raw JSON escape hatches.

Realtime

Realtime supports:

  • Phoenix WebSocket connection.
  • Postgres changes.
  • Broadcast.
  • Presence.
  • Subscribe/unsubscribe.
  • Track/untrack.
  • Active channel inspection.
  • setAuth.
  • Connection state via StateFlow.
  • Auto-reconnect with backoff.
  • Debug state/events.
  • Manual heartbeat sending for diagnostics.

Edge Functions

Functions support:

  • Invoke.
  • Invoke with body.
  • Typed invoke.
  • Unit-return helpers.
  • Per-call headers.
  • setAuth.

Code Comparison: Database Select with Filter

Official-style SDK usage often feels like this:

val data = supabase.postgrest["cities"]
    .select {
        City::name eq "Paris"
    }
    .decodeList<City>()

supabase-kmp keeps it direct and result-based:

val result = database.selectTyped<City>(
    table = "cities",
) {
    eq("name", "Paris")
}

Then handle the result:

result
    .onSuccess { cities -> println(cities) }
    .onFailure { error -> println(error.message) }

No hidden throw path for normal API failure.

The Sample App Is Real, Not Fake

I also built a Jetpack Compose chat sample.

The goal is simple:

If someone provides a Supabase project URL and anon key, the sample should behave like a real Supabase app.

It exercises:

  • Auth sign-up/sign-in.
  • Anonymous auth.
  • Session restore and refresh.
  • Database select, insert, RPC, CSV, HEAD, filters, ordering, pagination.
  • Realtime Postgres changes.
  • Broadcast.
  • Presence.
  • Storage upload, list, info, download, signed URLs, public/authenticated URLs, remove.
  • Edge Functions raw and typed invoke.

Some things are intentionally not in the anon-key sample.

Admin APIs do not belong in a mobile client. Destructive bucket management should not run casually in a sample app. Passkey and wallet UI flows need platform-specific ceremony.

That is not fake coverage.

That is responsible coverage.

What Changed Since v0.1.0

In the first article, I listed things I had not built yet.

That list changed.

Anonymous sign-in: built.

Identity linking: built.

SSO: built.

MFA: built.

Passkey API endpoints: built.

Auth Admin: built as a separate service-role module.

Storage vector and analytics APIs: built.

Iceberg catalog helpers: built, now with typed models.

Realtime diagnostics: built.

Supabase JavaScript SDK coverage tracking: added.

The SDK is still lean, but it is no longer tiny because it is missing obvious things. It is lean because the architecture stays direct.

The Numbers

The exact line count changes every release, so I do not want to pretend the old 3,600 lines number is still the full story.

But the design target is still the same:

Metric Official-style SDK approach supabase-kmp approach
Error handling Exceptions / thrown failures SupabaseResult<T>
Entity identity Mostly strings Value-class IDs where useful
Architecture Plugin/framework style Direct module composition
Module usage Central client install model Pick the Maven modules you need
Async model Kotlin coroutines Kotlin coroutines
Typed data Decode after request Typed helpers with Kotlin serialization
Platform ceremonies Often SDK-shaped App/platform owns native ceremony

The point is not just fewer lines.

The point is less architectural weight.

What I Didn’t Build Yet

The SDK now covers the current tracked JavaScript reference surface, but there is still work worth doing.

The sample app can exercise more SDK APIs:

  • Database update/upsert/delete diagnostics.
  • More advanced filters.
  • Storage move/copy and signed upload flows.
  • Realtime untrack and remove-channel flows.
  • OTP UI.
  • SSO UI.
  • Identity link/unlink UI.
  • Typed Edge Function body calls.

And later, I may add optional platform adapters for:

  • Android/iOS passkey credential ceremonies.
  • Web3 wallet prompts.

But those should be explicit adapters, not hidden inside the core SDK.

Why This Matters

I didn’t set out to replace every SDK.

I set out to build the Supabase SDK I wanted to use in Kotlin Multiplatform apps.

Result monads make errors explicit.

Value classes prevent ID mixups.

Independent modules keep apps lean.

Direct composition makes lifecycle ownership obvious.

Kotlin serialization gives typed data without pretending Kotlin is JavaScript.

This is the core idea behind supabase-kmp:

Use Supabase fully.

But make it feel like Kotlin.

Try It Out

The repository is live at:

github.com/AndroidPoet/supabase-kmp

The latest release is v0.3.2 on Maven Central.

If you are building a Kotlin Multiplatform app with Supabase and you want explicit results, typed APIs, modular dependencies, and direct composition, give it a try.

Less can still be more.

But now, less does not mean incomplete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment