Use SupaHost with Android and Kotlin

Connect an Android app to a SupaHost project with supabase-kt. Gradle dependencies, keys through BuildConfig, a client with Auth and Postgrest, a Compose list, and sign-in with an emailed code.

supabase-kt is the Kotlin client for Supabase, used on Android and in Kotlin Multiplatform. It's built on Ktor and kotlinx.serialization, and installs features as plugins. Give it your SupaHost project URL and anon key and it works unchanged.

The examples use Jetpack Compose and the recipes table from the quickstart.

Add the dependencies

In your app module's build.gradle.kts, add the serialization plugin, the supabase-kt BOM and the modules you need, plus a Ktor engine:

plugins {
    // ...
    kotlin("plugin.serialization") version "<your Kotlin version>"
}

dependencies {
    implementation(platform("io.github.jan-tennert.supabase:bom:<supabase-kt version>"))
    implementation("io.github.jan-tennert.supabase:postgrest-kt")
    implementation("io.github.jan-tennert.supabase:auth-kt")
    implementation("io.ktor:ktor-client-android:<ktor version>")
}

Take the current versions from the supabase-kt releases page on GitHub; each release lists the Kotlin and Ktor versions it needs. Add the internet permission to AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Keep keys out of source

Add the Project URL and anon key from the Connect tab (subtab App) to local.properties, which Android Studio already ignores in git:

SUPABASE_URL=https://app-one.acme.supahost.space
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

Expose them through BuildConfig:

// app/build.gradle.kts
import java.util.Properties

val localProps = Properties().apply {
    rootProject.file("local.properties").inputStream().use { load(it) }
}

android {
    buildFeatures { buildConfig = true }
    defaultConfig {
        buildConfigField("String", "SUPABASE_URL", "\"${localProps["SUPABASE_URL"]}\"")
        buildConfigField("String", "SUPABASE_ANON_KEY", "\"${localProps["SUPABASE_ANON_KEY"]}\"")
    }
}

The anon key is fine inside an APK. The service_role key never is.

Create the client

// SupabaseClient.kt
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest

val supabase = createSupabaseClient(
    supabaseUrl = BuildConfig.SUPABASE_URL,
    supabaseKey = BuildConfig.SUPABASE_ANON_KEY,
) {
    install(Auth)
    install(Postgrest)
}

Create it once and reuse it. The Auth plugin saves the session on the device and refreshes it.

Query a table

import io.github.jan.supabase.postgrest.from
import io.github.jan.supabase.postgrest.query.Columns
import io.github.jan.supabase.postgrest.query.Order
import kotlinx.serialization.Serializable

@Serializable
data class Recipe(val id: Long, val title: String, val minutes: Int? = null)

suspend fun loadRecipes(): List<Recipe> =
    supabase.from("recipes")
        .select(Columns.list("id", "title", "minutes")) {
            order("created_at", Order.DESCENDING)
        }
        .decodeList<Recipe>()

Show them in Compose:

@Composable
fun RecipeList() {
    var recipes by remember { mutableStateOf<List<Recipe>>(emptyList()) }
    var error by remember { mutableStateOf<String?>(null) }

    LaunchedEffect(Unit) {
        runCatching { loadRecipes() }
            .onSuccess { recipes = it }
            .onFailure { error = it.message }
    }

    error?.let { Text("Error: $it") }
    LazyColumn {
        items(recipes, key = { it.id }) { r ->
            Text("${r.title} (${r.minutes ?: "?"} min)", modifier = Modifier.padding(12.dp))
        }
    }
}

Inserting takes any serializable value:

@Serializable
data class NewRecipe(val title: String, val minutes: Int?)

supabase.from("recipes").insert(NewRecipe("Lemon rice", 30))

The insert only succeeds for a signed-in user, because the quickstart's insert policy is for authenticated.

Sign in

Email and password:

import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email

supabase.auth.signUpWith(Email) {
    email = "[email protected]"
    password = "a-long-password"
}
supabase.auth.signInWith(Email) {
    email = "[email protected]"
    password = "a-long-password"
}
supabase.auth.signOut()

An emailed one-time code is the easiest passwordless option on Android, because a typed code needs no redirect. (If you prefer links, add your app's scheme, such as recipebox://login, to Redirect URLs on the Auth tab and handle the deep link.)

import io.github.jan.supabase.auth.OtpType
import io.github.jan.supabase.auth.providers.builtin.OTP

supabase.auth.signInWith(OTP) { email = "[email protected]" }
// Later, with the code from the email:
supabase.auth.verifyEmailOtp(type = OtpType.Email.EMAIL, email = "[email protected]", token = code)

Watch the session

import io.github.jan.supabase.auth.status.SessionStatus

LaunchedEffect(Unit) {
    supabase.auth.sessionStatus.collect { status ->
        signedIn = status is SessionStatus.Authenticated
    }
}

Google sign-in

Use Android's Credential Manager to get a Google ID token, then sign in with it. supabase-kt's compose-auth module can do both steps for you. With a token in hand:

import io.github.jan.supabase.auth.providers.Google
import io.github.jan.supabase.auth.providers.builtin.IDToken

supabase.auth.signInWith(IDToken) {
    idToken = googleIdToken
    provider = Google
}

Enable Google on the project's Auth tab first; see Sign in with Google. Browser-based OAuth needs an https:// App Link as the return address, listed under Redirect URLs.

Next steps

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us