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.

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:

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
productstable (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)
- Create the project and install the client.
npm create vite@latest my-react-supabase-app -- --template react
npm install @supabase/supabase-js- Add environment variables in
.env.local.
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key- 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)- 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 AppWeb - Next.js
- Create the project with the official Supabase template (it installs
@supabase/supabase-jsand@supabase/ssr, and generates browser/server clients for you).
npx create-next-app@latest my-next-supabase-app -e with-supabase- Add environment variables in
.env.local.
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key- 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.
}
},
},
},
);
}- 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>
);
}- Allow public access to
/productsby updating the auth condition inlib/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)
- 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- Add environment variables in
.env(Expo requires theEXPO_PUBLIC_prefix to expose them to app code).
EXPO_PUBLIC_SUPABASE_URL=your-project-url
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key- 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,
},
});- 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
- Add the Supabase Swift package. In Xcode: File → Add Package Dependencies…, enter
https://github.com/supabase/supabase-swift.git, and add theSupabaseproduct. - 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.plistand read withBundle.main.
import Foundation
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "https://YOUR_PROJECT_ID.supabase.co")!,
supabaseKey: "YOUR_PUBLISHABLE_KEY"
)- Create a model in
Product.swift.
import Foundation
struct Product: Decodable, Identifiable {
let id: Int
let name: String
}- 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)
- 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)
}- Grant internet access in
app/src/main/AndroidManifest.xml.
<uses-permission android:name="android.permission.INTERNET" />- Create the client in
data/supabase/SupabaseClient.kt(for production, keep secrets inlocal.propertiesand expose them viaBuildConfig).
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)
}- Create a
@Serializablemodel and fetch products inMainActivity.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
errorobject 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
| Feature | Supabase | Firebase |
|---|---|---|
| Database | PostgreSQL (relational) | Firestore (NoSQL) |
| Auto-generated APIs | Yes (REST + Realtime) | Limited |
| Authentication | Yes | Yes |
| Storage | Yes | Yes |
| Open source / self-host | Yes | No |
| Best for | Relational data, SQL teams | Document 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
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.


