Supabase

How to Integrate Supabase with Web, Android, and iOS Applications

Learn how to integrate Supabase with React, Next.js, React Native, native iOS, and Android apps with practical setup steps, code examples, and best practices.

Aarav Sharma

Aarav Sharma

August 24, 20268 min read
Share
Cross-platform Supabase integration for web, Android, and iOS applications

Cross-platform architecture derived from the guide: each client uses a platform-specific Supabase library to access the same PostgREST API and PostgreSQL database.

Introduction

This guide is based on a proof of concept that connected the same products table to React, Next.js, React Native (Expo), native iOS (SwiftUI), and native Android (Kotlin). It covers the Supabase client setup and data query used on each platform.

Quick Answer

Each platform follows the same setup: install the client library, configure the project URL and publishable (anon) key, create the client, and query the database. The web uses @supabase/supabase-js, React Native uses the same library with a URL polyfill and AsyncStorage, iOS uses supabase-swift, and Android uses the Kotlin community SDK with a Ktor engine. The query pattern (from("products").select()) is similar across the stacks.

What Is Supabase?

Supabase is an open-source backend platform built around PostgreSQL. It generates REST and Realtime APIs from your tables and provides authentication, file storage, and edge functions. Clients can call these services directly with a publishable key, while Row Level Security (RLS) controls database access.

The Problem

Supporting the same feature across web, Android, and iOS normally means:

  • Standing up and hosting a custom backend and REST API.
  • Re-implementing data access, authentication, and session handling per platform.
  • Keeping API contracts in sync across three separate teams or codebases.
  • Managing database migrations, security rules, and connection details in multiple places.

For a small team, maintaining these pieces adds development and maintenance work.

The Solution

With Supabase, the database is the API. Every platform talks to the same PostgreSQL instance through the Supabase client library for that language. The basic architecture is the same on each platform:

Supabase architecture connecting React, React Native, native iOS, and native Android applications to PostgreSQL through the Supabase client and PostgREST API with Row Level Security

You define the table and security rules once. Each client then needs the project URL, key, and client instance. The query pattern is similar across languages.

Prerequisites

  • A Supabase account and a project with a products table (columns: id, name, price) for demo.
  • Your project URL and publishable (anon) key from the Supabase dashboard.
  • Node.js (for web and Expo), Xcode (for iOS), and Android Studio (for Android).
  • Basic knowledge of JavaScript/TypeScript, Swift, and Kotlin for the respective sections.
The publishable/anon key can be included in client apps. Row Level Security controls data access; hiding the key does not.

Step-by-Step Implementation

Each platform follows four steps: install the client → add credentials → create the client → query the table.

Web - React (Vite)

  1. Create the project and install the client.
npm create vite@latest my-react-supabase-app -- --template react
npm install @supabase/supabase-js
  1. Add environment variables in .env.local.
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
  1. Create the Supabase client at src/lib/supabaseClient.js.
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY

export const supabase = createClient(supabaseUrl, supabasePublishableKey)
  1. Fetch and render products in App.jsx.
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabaseClient'

function App() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetchProducts()
  }, [])

  async function fetchProducts() {
    setLoading(true)
    setError(null)

    const { data, error } = await supabase
      .from('products')
      .select('*')
      .order('id', { ascending: true })

    if (error) {
      setError(error.message)
      setLoading(false)
      return
    }

    setProducts(data)
    setLoading(false)
  }

  return (
    <main>
      <h1>Supabase Web Demo</h1>
      {loading && <p>Loading products...</p>}
      {error && <p>Failed to load products: {error}</p>}
      {!loading && !error && (
        <ul>
          {products.map((product) => (
            <li key={product.id}>{product.name} - ${product.price}</li>
          ))}
        </ul>
      )}
    </main>
  )
}

export default App

Web - Next.js

  1. Create the project with the official Supabase template (it installs @supabase/supabase-js and @supabase/ssr, and generates browser/server clients for you).
npx create-next-app@latest my-next-supabase-app -e with-supabase
  1. Add environment variables in .env.local.
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
  1. Create the supabase server client at lib/supabase/server.ts.
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

/**
 * Especially important if using Fluid compute: Don't put this client in a
 * global variable. Always create a new client within each function when using
 * it.
 */
export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have proxy refreshing
            // user sessions.
          }
        },
      },
    },
  );
}
  1. Create a server-rendered products page at app/products/page.tsx.
import { createClient } from "@/lib/supabase/server";
import { Suspense } from "react";

async function ProductData() {
  const supabase = await createClient();
  const { data: products, error } = await supabase.from("products").select();

  if (error) {
    return <p>Error loading Products: {error.message}</p>;
  }

  return <pre>{JSON.stringify(products, null, 2)}</pre>;
}

export default function Products() {
  return (
    <Suspense fallback={<div>Loading Products...</div>}>
      <ProductData />
    </Suspense>
  );
}
  1. Allow public access to /products by updating the auth condition in lib/supabase/proxy.ts.
if (
  request.nextUrl.pathname !== "/" &&
  !user &&
  !request.nextUrl.pathname.startsWith("/login") &&
  !request.nextUrl.pathname.startsWith("/auth") &&
  request.nextUrl.pathname !== "/products" &&
  !request.nextUrl.pathname.startsWith("/products/")
) {
  // redirect unauthenticated users
}

Run npm run dev and open /products to see the list.

Mobile - React Native (Expo)

  1. Create the project and install dependencies (the polyfill and AsyncStorage are required for the client to work in React Native).
npx create-expo-app my-app --template blank-typescript
npx expo install @supabase/supabase-js react-native-url-polyfill @react-native-async-storage/async-storage
  1. Add environment variables in .env (Expo requires the EXPO_PUBLIC_ prefix to expose them to app code).
EXPO_PUBLIC_SUPABASE_URL=your-project-url
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
  1. Create the client at lib/supabase.ts.
import "react-native-url-polyfill/auto";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!;

export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
});
  1. Fetch and render products in App.tsx.
import { useEffect, useState } from 'react'
import { FlatList, StyleSheet, Text, View } from 'react-native'
import { supabase } from './lib/supabase'

type Product = { id: number; name: string; price: number }

export default function App() {
  const [products, setProducts] = useState<Product[]>([])
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    getProducts()
  }, [])

  async function getProducts() {
    const { data, error } = await supabase
      .from('products')
      .select('*')
      .order('id', { ascending: true })

    if (error) {
      setError(error.message)
      return
    }

    setProducts(data ?? [])
  }

  if (error) {
    return (
      <View style={styles.container}>
        <Text style={styles.error}>Error loading products: {error}</Text>
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Products</Text>
      <FlatList
        data={products}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={styles.item}>
            <Text style={styles.name}>{item.name}</Text>
            <Text style={styles.price}>${item.price.toFixed(2)}</Text>
          </View>
        )}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff', paddingTop: 50, paddingHorizontal: 16 },
  title: { fontSize: 24, fontWeight: '600', marginBottom: 16 },
  item: { paddingVertical: 16, borderBottomWidth: 1, borderBottomColor: '#ccc' },
  name: { fontSize: 18, fontWeight: '500' },
  price: { fontSize: 16, marginTop: 4, color: '#666' },
  error: { color: 'red' },
})

Start the dev server, then run npx expo run:android --device (or run:ios) to launch on a device.

Native iOS - SwiftUI

  1. Add the Supabase Swift package. In Xcode: File → Add Package Dependencies…, enter https://github.com/supabase/supabase-swift.git, and add the Supabase product.
  2. Create the client in Supabase.swift. iOS has no built-in .env; for a POC place values directly, and for production surface them via an .xcconfig/Info.plist and read with Bundle.main.
import Foundation
import Supabase

let supabase = SupabaseClient(
    supabaseURL: URL(string: "https://YOUR_PROJECT_ID.supabase.co")!,
    supabaseKey: "YOUR_PUBLISHABLE_KEY"
)
  1. Create a model in Product.swift.
import Foundation

struct Product: Decodable, Identifiable {
    let id: Int
    let name: String
}
  1. Fetch and render products in ContentView.swift.
import SwiftUI

struct ContentView: View {
    @State private var products: [Product] = []

    var body: some View {
        NavigationView {
            List(products) { product in
                Text(product.name)
            }
            .navigationTitle("Products")
            .overlay {
                if products.isEmpty { ProgressView() }
            }
            .task { await fetchProducts() }
        }
        .navigationViewStyle(.stack)
    }

    private func fetchProducts() async {
        do {
            products = try await supabase
                .from("products")
                .select()
                .execute()
                .value
        } catch {
            dump(error)
        }
    }
}

Select a simulator or device and press Run (⌘R).

Native Android - Kotlin (Jetpack Compose)

  1. Add dependencies via the Gradle version catalog. In gradle/libs.versions.toml.
[versions]
supabase = "3.0.3"
ktor = "3.0.1"

[libraries]
supabase-bom = { module = "io.github.jan-tennert.supabase:bom", version.ref = "supabase" }
supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

In app/build.gradle.kts, apply the serialization plugin and add the dependencies (the BOM keeps module versions aligned):

plugins {
    alias(libs.plugins.kotlin.serialization)
}

dependencies {
    implementation(platform(libs.supabase.bom))
    implementation(libs.supabase.postgrest)
    implementation(libs.ktor.client.android)
}
  1. Grant internet access in app/src/main/AndroidManifest.xml.
<uses-permission android:name="android.permission.INTERNET" />
  1. Create the client in data/supabase/SupabaseClient.kt (for production, keep secrets in local.properties and expose them via BuildConfig).
package com.example.myandroidsupabaseapp.data.supabase

import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest

val supabase = createSupabaseClient(
    supabaseUrl = "https://YOUR_PROJECT_ID.supabase.co",
    supabaseKey = "YOUR_PUBLISHABLE_KEY"
) {
    install(Postgrest)
}
  1. Create a @Serializable model and fetch products in MainActivity.kt.
import kotlinx.serialization.Serializable

@Serializable
data class Product(val id: Int, val name: String, val price: Double)

@Composable
fun ProductList(modifier: Modifier = Modifier) {
    var products by remember { mutableStateOf<List<Product>>(emptyList()) }
    var error by remember { mutableStateOf<String?>(null) }

    LaunchedEffect(Unit) {
        try {
            products = supabase.from("products").select().decodeList<Product>()
        } catch (e: Exception) {
            error = e.message
        }
    }

    if (error != null) {
        Text(text = "Error loading products: $error", modifier = modifier)
        return
    }

    LazyColumn(modifier = modifier) {
        items(products) { product ->
            Text(text = "${product.name} - $${product.price}")
        }
    }
}

Press Run (▶) on an emulator or device and the product list appears.

Test the Implementation

A successful test on each platform shows the rows from the products table. If the list is empty without an error, check that the table has rows and that RLS allows reads.

Common Problems / Errors

1. Empty list but no error. Usually an RLS issue. If Row Level Security is enabled with no read policy, PostgREST returns an empty array. Add a SELECT policy allowing the anon role to read the table.

2. `Invalid API key` or 401 responses. Check that you copied the publishable (anon) key - not the service role key - and that the URL and key match the same project.

3. Environment variables are `undefined`. Prefixes matter: Vite uses VITE_, Next.js uses NEXT_PUBLIC_ for client-side variables, and Expo uses EXPO_PUBLIC_. After changing .env files, reload or restart your development/build process as required by the framework.

4. React Native: "URL.protocol is not implemented". The URL polyfill isn't loaded. Ensure import "react-native-url-polyfill/auto" is the first line of your Supabase client file, and that AsyncStorage is installed.

5. Android: network or serialization failures. Confirm the INTERNET permission is present, the Kotlin serialization plugin is applied, and your data class is annotated @Serializable.

Best Practices

  • Never ship the service role key in a client app - only the publishable/anon key belongs in frontend or mobile code.
  • Always enable Row Level Security and write explicit policies; the key is public, so the database must enforce access.
  • Use environment variables on every platform that supports them, and a git-ignored config (.xcconfig, local.properties) for native apps.
  • Select only the columns you need instead of select('*') in production to reduce payload size.
  • Handle the error object returned by every Supabase call rather than assuming success.
  • Add database indexes on columns you filter or order by (for example id, created_at).
  • Keep one client instance per app rather than creating a new client on every call.

Performance and Security Considerations

  • Security: RLS is your real security boundary. Treat the anon key as public and design policies as if anyone can call your API - because they can.
  • Performance: PostgREST supports pagination (range), ordering, and column selection - use them to avoid loading entire tables into a mobile client.
  • Scalability: Because all platforms hit the same PostgreSQL instance, connection pooling and proper indexing matter more than per-client tweaks.
  • Production config: Move hard-coded keys in the iOS/Android POC files into build-time config before release, and restrict policies to authenticated users where appropriate.

Alternatives / Comparison

FeatureSupabaseFirebase
DatabasePostgreSQL (relational)Firestore (NoSQL)
Auto-generated APIsYes (REST + Realtime)Limited
AuthenticationYesYes
StorageYesYes
Open source / self-hostYesNo
Best forRelational data, SQL teamsDocument data, tight Google stack

Supabase fits teams that want SQL and a relational model. Firebase fits document-oriented apps that rely on the Google ecosystem.

When Should You Use Supabase?

Supabase fits when:

  • You need a relational (PostgreSQL) database with real queries and joins.
  • You want the same backend to serve web, Android, and iOS.
  • You want authentication, storage, and APIs without building a backend.
  • You value open source and the option to self-host.

Consider alternatives when:

  • Your data is document-oriented and you don't need SQL.
  • You have unusual infrastructure requirements or a specialized existing backend.
  • You are fully committed to another cloud's managed services.

FAQ

Can Supabase be used with web and mobile apps at the same time? Yes. Supabase provides official and community client libraries for JavaScript/TypeScript, Swift, and Kotlin, so web (React, Next.js), React Native, native iOS, and native Android can all connect to the same database.

Is the Supabase anon key safe to include in a mobile or frontend app? Yes. The publishable/anon key is designed to be public. Actual data access is controlled by Row Level Security policies on the database, so you must configure RLS correctly.

Do I need a separate backend for a Supabase integration? No. Supabase auto-generates REST and Realtime APIs from your PostgreSQL tables, so most apps can talk to Supabase directly without a custom backend server.

Does the same query syntax work across platforms? Largely yes. The pattern from("products").select() is nearly identical in JavaScript, Swift, and Kotlin, though each language has small differences in how results are decoded.

Which library do I use for native iOS and Android? Use supabase-swift for iOS and the Kotlin community SDK (io.github.jan-tennert.supabase) with a Ktor engine for Android.

How do I handle authentication in a Supabase integration? Supabase Auth is built in. This guide focuses on data access, but the same client instance exposes auth methods for email, OAuth, and phone sign-in on every platform.

External References

Need help building a cross-platform app with Supabase? Matlab Infotech designs, develops, and ships production web and mobile applications on Supabase, Next.js, and React Native. Contact us to discuss your project.

Aarav Sharma

Aarav Sharma

Lead Software Engineer

Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.

Related articles

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts