惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tailwind CSS Blog
大猫的无限游戏
大猫的无限游戏
L
LINUX DO - 热门话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
雷峰网
雷峰网
aimingoo的专栏
aimingoo的专栏
博客园_首页
MongoDB | Blog
MongoDB | Blog
V
V2EX
GbyAI
GbyAI
量子位
Microsoft Azure Blog
Microsoft Azure Blog
有赞技术团队
有赞技术团队
G
Google Developers Blog
云风的 BLOG
云风的 BLOG
B
Blog
Microsoft Security Blog
Microsoft Security Blog
S
SegmentFault 最新的问题
O
OpenAI News
N
News and Events Feed by Topic
博客园 - Franky
爱范儿
爱范儿
Forbes - Security
Forbes - Security
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
V2EX - 技术
V2EX - 技术
Application and Cybersecurity Blog
Application and Cybersecurity Blog
N
News and Events Feed by Topic
N
News | PayPal Newsroom
Schneier on Security
Schneier on Security
Cloudbric
Cloudbric
Security Archives - TechRepublic
Security Archives - TechRepublic
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Recent Commits to openclaw:main
Recent Commits to openclaw:main
人人都是产品经理
人人都是产品经理
P
Privacy International News Feed
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
阮一峰的网络日志
阮一峰的网络日志
D
DataBreaches.Net
Last Week in AI
Last Week in AI
罗磊的独立博客
Spread Privacy
Spread Privacy
Recent Announcements
Recent Announcements
The Cloudflare Blog
Google DeepMind News
Google DeepMind News
AWS News Blog
AWS News Blog
The Register - Security
The Register - Security
Y
Y Combinator Blog
J
Java Code Geeks
I
Intezer

Supabase Blog

AI Agents Know About Supabase. They Don't Always Use It Right. Custom OIDC Providers for Supabase Auth 100,000 GitHub stars Supabase docs over SSH Navigating Regional Network Blocks Supabase Joins the Stripe Projects Developer Preview Log Drains: Now available on Pro Supabase Storage: major performance, security, and reliability updates Supabase incident on February 12, 2026 Hydra joins Supabase X / Twitter OAuth 2.0 is now available for Supabase Auth BKND joins Supabase Supabase is now an official Claude connector Supabase PrivateLink is now available Introducing: Postgres Best Practices When to use Read Replicas vs. bigger compute Introducing TRAE SOLO integration with Supabase Supabase Security Retro: 2025 Sync Stripe Data to Your Supabase Database in One Click Building ChatGPT Apps with Supabase Edge Functions and mcp-use Own Your Observability: Supabase Metrics API Introducing iceberg-js: A JavaScript Client for Apache Iceberg Introducing Supabase for Platforms Adding Async Streaming to Postgres Foreign Data Wrappers Build "Sign in with Your App" using Supabase Auth Introducing Seven New Email Templates for Supabase Auth The new Supabase power for Kiro Introducing Supabase ETL Introducing Analytics Buckets Introducing Vector Buckets Snap, Inc. Launches Snap Cloud, Powered by Supabase Triplit joins Supabase Supabase Series E 1000 Y Combinator Founders Choose Supabase gm 👋 web3, welcome aboard to Sign in with Web3 (Solana, Ethereum) Announcing the Supabase Remote MCP Server Enterprise speed, enterprise standards with Bolt Cloud + Supabase PostgREST 13 Lovable Cloud + Supabase: The Default Platform for AI Builders Processing large jobs with Edge Functions, Cron, and Queues Defense in Depth for MCP Servers OrioleDB Patent: now freely available to the Postgres community Supabase Launch Week 15 Hackathon Winner Announcement The Vibe Coder's Guide to Supabase Environments Testing for Vibe Coders: From Zero to Production Confidence The Vibe Coding Master Checklist Vibe Coding: Best Practices for Prompting Supabase Auth: Build vs. Buy Top 10 Launches of Launch Week 15 Supabase Launch Week 15 Hackathon Storage: 10x Larger Uploads, 3x Cheaper Cached Egress, and 2x Egress Quota Persistent Storage and 97% Faster Cold Starts for Edge Functions Algolia Connector for Supabase New Observability Features in Supabase Improved Security Controls and A New Home for Security Introducing Branching 2.0 Stripe-To-Postgres Sync Engine as standalone Library Supabase Analytics Buckets with Iceberg Support Create a Supabase backend using Figma Make Introducing JWT Signing Keys Supabase UI: Platform Kit Build a Personalized AI Assistant with Postgres Announcing Multigres: Vitess for Postgres Building on open table formats Open Data Standards: Postgres, OTel, and Iceberg Simplifying back-end complexity with Supabase Data APIs PostgreSQL Event Triggers without superuser access Top 10 Launches of Launch Week 14 Supabase MCP Server Data API Routes to Nearest Read Replica Declarative Schemas for Simpler Database Management Realtime: Broadcast from Database Keeping Tabs on What's New in Supabase Studio Edge Functions: Deploy from the Dashboard + Deno 2.1 Automatic Embeddings in Postgres Introducing the Supabase UI Library Supabase Auth: Bring Your Own Clerk Postgres Language Server: Initial Release Migrating from Fauna to Supabase Migrating from the MongoDB Data API to Supabase Dedicated Poolers Postgres as a Graph Database: (Ab)using pgRouting AI Hackathon at Y Combinator Calendars in Postgres using Foreign Data Wrappers Supabase Launch Week 13 Hackathon Winners How to Hack the Base! Running Durable Workflows in Postgres using DBOS database.build v2: Bring-your-own-LLM Restore to a New Project Hack the Base! with Supabase Top 10 Launches of Launch Week 13 Supabase Queues High Performance Disk Supabase Cron Supabase CLI v2: Config as Code Supabase Edge Functions: Introducing Background Tasks, Ephemeral Storage, and WebSockets Supabase AI Assistant v2 OrioleDB Public Alpha Executing Dynamic JavaScript Code on Supabase with Edge Functions ClickHouse Partnership, improved Postgres Replication, and Disk Management
Offline-first React Native Apps with Expo, WatermelonDB, and Supabase
Benedikt Müller · 2023-10-08 · via Supabase Blog

Offline-first React Native Apps with Expo, WatermelonDB, and Supabase

Using the Supabase JavaScript Client in a React Native app, it's super easy to fetch data from a Supabase backend by simply calling functions like supabase.from('countries').select() wherever the data is needed. However, another approach to handling data in mobile apps recently gained a lot of popularity: Offline-first - meaning an app works primarily offline and synchronizes data whenever possible.

This has multiple advantages, the obvious being that the app can be used offline. But even when the app is used online, the user experience still profits from this approach as reading and updating data feels instantaneous as it happens on the device first and is then synced in the background. The approach also has drawbacks: Data can become stale, and if it is changed in multiple places while offline, data conflicts can occur.

There are sophisticated database technologies like CRDT (conflict-free replicated data type), which are also making their way into mobile apps. For our example, we will not reach for a CRDT and instead use WatermelonDB, an open-source solution built on SQLite that includes a sync engine and applies the simple strategy of just letting the latest change of a record win. Follow along to build a React Native/Expo app that uses WatermelonDB as the offline-first data store and syncs to a Supabase backend using Remote Procedure Calls and Supabase Realtime to trigger sync on other devices.

The approach described in this blog post was developed while building Share My Stack. This mobile app allows people to curate and share their personal productivity stack as well as their favorite development stack. The app is available on the App Store, and the complete source code is available on GitHub for educational purposes. Here is a little demo of what the result looks like when using the app on two separate devices:

For this tutorial, we'll be implementing a small part of the Share My Stack data model, namely the ability to have a "profile," which belongs to a "user" (stored in auth.users) and can have multiple "stacks" associated with it (see the output from the Supabase Schema Visualizer below for illustration). With that said, let's dive into building our local-first app ...


_10

npx create-expo-app OfflineFirstWithSupabase


The first step is to add the required dependencies to your React Native project: WatermelonDB, Supabase, and expo-build-properties, which we'll use in the next step to customize the React Native app build.


_10

npm install @nozbe/watermelondb @supabase/supabase-js expo-build-properties


WatermelonDB is depending on simdjson, which needs to be added to the Podfile of the React Native project. Using expo-build-properties, this can be done with a simple configuration change without touching native iOS project files.


_21

{

_21

"expo": {

_21

"plugins": [

_21

[

_21

"expo-build-properties",

_21

{

_21

"ios": {

_21

"extraPods": [

_21

{

_21

"name": "simdjson",

_21

"configurations": ["Debug", "Release"],

_21

"path": "../node_modules/@nozbe/simdjson",

_21

"modular_headers": true

_21

}

_21

]

_21

}

_21

}

_21

]

_21

]

_21

}

_21

}


WatermelonDB requires models to be created for the data that is stored and synced through it. Take a look at their docs for the details; here is an excerpt of the model used in Share My Stack, which represents a "profile" that has multiple "stacks" associated with it:


_20

import { Model, Q, Relation } from "@nozbe/watermelondb";

_20

import { date, readonly, text } from "@nozbe/watermelondb/decorators";

_20

_20

import { Stack } from "./Stack";

_20

_20

export class Profile extends Model {

_20

static table = "profiles";

_20

_20

@readonly @date("created_at") createdAt!: Date;

_20

@readonly @date("updated_at") updatedAt!: Date;

_20

_20

static associations = {

_20

["stacks"]: {

_20

type: "has_many" as const,

_20

foreignKey: "profile_id",

_20

}

_20

};

_20

_20

@text("name") name!: string;

_20

@text("website") website!: string;


The WatermelonDB docs outline how to set up a sync function that talks with the backend server. In our case, the backend is a Postgres database hosted on Supabase. As indicated earlier, we'll implement the sync via two Postgres functions called via Supabase RPC. This way, each sync is only two database calls (one push, one pull), and most of the logic is carried out directly within the database.


_21

import { SyncDatabaseChangeSet, synchronize } from '@nozbe/watermelondb/sync'

_21

_21

await synchronize({

_21

database,

_21

pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {

_21

const { data, error } = await supabase.rpc('pull', {

_21

last_pulled_at: lastPulledAt,

_21

})

_21

_21

const { changes, timestamp } = data as {

_21

changes: SyncDatabaseChangeSet

_21

timestamp: number

_21

}

_21

_21

return { changes, timestamp }

_21

},

_21

pushChanges: async ({ changes, lastPulledAt }) => {

_21

const { error } = await supabase.rpc('push', { changes })

_21

},

_21

sendCreatedAsUpdated: true,

_21

})


For the local synchronize function to be able to call Postgres functions (pull and push) via supabase.rpc(), those functions have to be created via migrations before. The specific implementation of those functions will be based on the schema of the data. The push function basically receives a set of data changes as a JSON object and writes them to the database, while the pull function receives the timestamp of the last sync and compiles a JSON object containing all changes the database has accumulated since this timestamps and returns it to the client.

Here is an example of a push function. This function implements the creation, deletion, and updating of profiles and would need to be extended for every additional object in the data model.


_27

create or replace function push(changes jsonb) returns void as $$

_27

declare new_profile jsonb;

_27

declare updated_profile jsonb;

_27

begin

_27

-- create profiles

_27

for new_profile in

_27

select jsonb_array_elements((changes->'profiles'->'created')) loop perform create_profile(

_27

(new_profile->>'id')::uuid,

_27

(new_profile->>'user_id')::uuid,

_27

(new_profile->>'name'),

_27

(new_profile->>'website'),

_27

epoch_to_timestamp(new_profile->>'created_at'),

_27

epoch_to_timestamp(new_profile->>'updated_at')

_27

);

_27

end loop;

_27

-- delete profiles

_27

with changes_data as (

_27

select jsonb_array_elements_text(changes->'profiles'->'deleted')::uuid as deleted

_27

)

_27

-- update profiles

_27

update profiles

_27

set deleted_at = now(),

_27

last_modified_at = now()

_27

from changes_data

_27

where profiles.id = changes_data.deleted;

_27

end;

_27

$$ language plpgsql;


And here is how the pull function might look like:


_51

create or replace function pull(last_pulled_at bigint default 0) returns jsonb as $$

_51

declare _ts timestamp with time zone;

_51

_profiles jsonb;

_51

begin -- timestamp

_51

_ts := to_timestamp(last_pulled_at / 1000);

_51

--- profiles

_51

select jsonb_build_object(

_51

'created',

_51

'[]'::jsonb,

_51

'updated',

_51

coalesce(

_51

jsonb_agg(

_51

jsonb_build_object(

_51

'id',

_51

t.id,

_51

'name',

_51

t.name,

_51

'website',

_51

t.website,

_51

'created_at',

_51

timestamp_to_epoch(t.created_at),

_51

'updated_at',

_51

timestamp_to_epoch(t.updated_at)

_51

)

_51

) filter (

_51

where t.deleted_at is null

_51

and t.last_modified_at > _ts

_51

),

_51

'[]'::jsonb

_51

),

_51

'deleted',

_51

coalesce(

_51

jsonb_agg(to_jsonb(t.id)) filter (

_51

where t.deleted_at is not null

_51

and t.last_modified_at > _ts

_51

),

_51

'[]'::jsonb

_51

)

_51

) into _profiles

_51

from sync_profiles_view t;

_51

return jsonb_build_object(

_51

'changes',

_51

jsonb_build_object(

_51

'profiles',

_51

_profiles

_51

),

_51

'timestamp',

_51

timestamp_to_epoch(now())

_51

);

_51

end;

_51

$$ language plpgsql;


To convert between timestamp formats used by WatermelonDB and Supabase/Postgres, the push and pull functions use the following utility functions in Supabase:


_12

create or replace function epoch_to_timestamp(epoch text) returns timestamp with time zone as $$ begin return timestamp with time zone 'epoch' + ((epoch::bigint) / 1000) * interval '1 second';

_12

end;

_12

$$ language plpgsql;

_12

_12

create or replace function timestamp_to_epoch(ts timestamp with time zone) returns bigint as $$ begin return (

_12

extract(

_12

epoch

_12

from ts

_12

) * 1000

_12

)::bigint;

_12

end;

_12

$$ language plpgsql;


I'm convinced local-first will be an increasingly important paradigm in the future - not for all apps, but at least for some. It can greatly enhance user experience with regards to reliability and performance and as we've seen in this tutorial, it doesn't need to be rocket science.

It does, however, come with its own set of trade-offs. One of the biggest drawbacks of the demonstrated approach, in my opinion, is the amount of friction it adds to changing the data model. Usually, when just adding the Supabase SDK, I add a new migration, re-generate the types, and use the data wherever I need to.

With the approach described above, I need to maintain the database schema in Supabase, and the local schema in WatermelonDB, and keep the sync functions up to date. In the end, for an app where the data model is not expected to change much and where very performant user interactions are key to adoption, this is still a very viable approach from my perspective.