This article was co-written with @yujiazuhata
Blackouts, slow signals, bad connection, or a forgotten internet bill— ever had any of these ruin your experience while working?
If you want to avoid these problems, going local-first on building your webs and apps is the way. Saving your data locally and automatically syncing it up whenever possible— imagine Git but for your data!
Here we’ll guide you through the implementation of ElectricSQL to build local-first, and hopefully you’ll pick up something along the way.
☁️ Cloud-First Syncing ☁️
What is Cloud-first?
- A cloud-first approach means prioritizing the cloud technology for application deployments and the like. Going Cloud-First means being economically flexible and having a competitive edge, especially nowadays where businesses are heavily leaning towards this approach.
Cloud Disadvantages
- Cloud-first is not always the optimal choice as it has its own disadvantages; cost inefficiencies, security concerns, and latency changes leading to performance challenges. It is important to note that a cloud service relies on a stable internet connection, thus the lack of it in certain environments— such as airlines and warehouses— severely impacts the intended usefulness of this approach.
🏠 Then Why Local-First? 🏠
What is Local-first?
- The main idea of going local-first is that the user's data will be kept on their device in a local database, which will then sync to the cloud in the background whenever possible. Going local-first offers a fast and cheap way to build apps and decreases resource requirements— a few beneficial reasons among many others.
Local-first Framework
Paradigm Shift
- A paradigm shift is currently happening in the software industry, from cloud-controlled systems to local-first ones, due to the user’s want for data ownership and control. This too however, is not the last time a shift will happen, in the future a more balanced local and cloud infrastructure combination will be found.
Read more about the Paradigm Shift here.
⚡ ElectricSQL ⚡
Why ElectricSQL?
- ElectricSQL is the bridge, or rather, the synchronization layer that connects a local database to PostgreSQL using logical replication. It is a Postgres sync engine that enables the developers to build apps with a local SQL database and a PostgreSQL database.
- Users can make changes locally, ElectricSQL syncs to PostgreSQL, the changes made will be received by other users and vice versa, and the conflicts will be resolved using rich CRDTs.
Local-first with ElectricSQL Framework
Integrating ElectricSQL to Your Web App for Local-first Syncing: A Beginner-friendly Guide 🧭
Interested but scared, or feeling overwhelmed? Worry not! This step-by-step guide will help you experience your first local-first syncing.
With the brief session out of the way, let’s get started on the how-to part.
Guide 1: No Existing React App? Worry Not!
Step 0: The Pre-requisites (What You Need Before You Start)
Before dropping local-first syncing into your existing web application, ensure you have the following ready:
- Make sure Docker Desktop is installed and running on your machine. This application/tool is necessary for running the local database and sync engine. You may go to , download the version for your computer (Mac or Windows), double-click the installer, and follow the setup wizard.
- Make sure Node.js is also installed. This will be used for managing your backend and frontend packages.
- Basic PostgreSQL Knowledge.
Step 1: Wiring Up the Sync Engine
ElectricSQL acts as a middleman between your PostgreSQL database and your frontend app. It requires a Postgres database with logical replication enabled. The easiest way to run both locally for development is using Docker Compose.
Create a new project folder. In this tutorial, the developers named the project folder as “Project Folder Name”.

Create a new “docker-compose.yml” file in your project's root folder. Make sure your computer isn't hiding file extensions. The file must end in .yml, not .yml.txt. Here's what the new YAML file should look like in your project’s root folder.

-
Open the newly created YAML file and add the configuration for Postgres and Electric to it. Feel free to modify the PostgreSQL database name, user, and password (don’t forget to also change the succeeding database name, user, and password later!).
services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: your_app_db POSTGRES_USER: postgres POSTGRES_PASSWORD: password ports: - '5432:5432' command: - -c - wal_level=logical # Crucial for Electric to detect changes electric: image: electricsql/electric:latest environment: - DATABASE_URL=postgresql://postgres:password@postgres:5432/your_app_db - ELECTRIC_INSECURE=true # For local development only ports: - '3000:3000' depends_on: - postgresDON'T FORGET TO SAVE
-
Open a new terminal on VS Code and run this command. Attached also is a successful result:
docker compose up -d A successful run of the previous command in step 4 creates a new container on Docker. To confirm this creation, open Docker Desktop and check whether the infrastructure was created. Here is an example of what a successful creation looks like:

Almost done with Step 1! Now it’s time to connect to your database. But first, make sure your container is turned on. (under the Actions column). If the container is turned off, the database is essentially powered down, and subsequent steps may fail. Here’s what it should look like:

-
in your terminal. This command allows you to run PostgresSQL commands in your terminal.
docker compose exec postgres psql -U postgres -d your_app_db Once successful, your terminal prompt will change to your_app_db=# (the # symbol means you have full admin privileges). This is what we call the PostgreSQL Command Prompt. This is what we call the From here, you are ready to create the tables your app needs:

-
For our example project, the developers created a table with the name ‘todos’ and with attributes id, title, and key. Once you run the SQL statement in your PostgreSQL command prompt, you should be able to see the result CREATE TABLE, to know that your table was created. Run this query:
CREATE TABLE todos (id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, completed BOOLEAN DEFAULT false); Once you see the “CREATE TABLE” result on the terminal, that means the table has been successfully created. And we are done with step 1!

Step 2: Build the Backend API
ElectricSQL magically handles the "Read Path" by automatically syncing data from your database directly to the frontend. However, to get new data into the database (the "Write Path"), we still need a standard backend API. Let's build a quick Node.js and Express server to catch our new tasks and save them to PostgreSQL
-
Open a new terminal, create an api folder, and set it up. To do so, run these commands one at a time on your new terminal.
mkdir api cd api npm init -y npm install express pg corsHere is what a successful run looks like. You should see the new packages added and etc.

-
Create an index.js file inside the api folder. If working on VS Code, right-click on the api folder and select ‘New File’. If not, manually create the file on File Explorer. Then, paste this code inside that new JavaScript file:
#JavaScript const express = require('express'); const cors = require('cors'); const { Client } = require('pg'); const app = express(); app.use(cors()); app.use(express.json()); const db = new Client({ connectionString: 'postgresql://postgres:password@localhost:5432/your_app_db' }); db.connect().then(() => console.log('✅ Connected to PostgreSQL')); app.post('/todos', async (req, res) => { try { const { id, title } = req.body; await db.query( 'INSERT INTO todos (id, title, completed) VALUES ($1, $2, $3)', [id, title, false] ); res.status(201).json({ message: 'Saved!' }); } catch (error) { res.status(500).json({ error: 'Error saving task' }); } }); app.listen(3001, () => console.log('🚀 API running on port 3001'));DON'T FORGET TO SAVE
Finally, run the node index.js command to start your backend server. This is what a successful result looks like:

Step 3: Writing the React App (Simple Task Tracker) with Local First Syncing
With our database engine running and our backend API ready to catch new data, it is time to build the face of our application! In this step, we will spin up a fresh React frontend. We will wire it up to read data directly from Electric's local memory in zero milliseconds, and use an "Optimistic UI" to ensure adding a task feels instantly fast—even if your user completely loses their Wi-Fi connection.
-
Open a new terminal window, and create a Vite React app by running this command:
npm create vite@latest client -- --template react-ts -
Click ‘No’ for using Vite Beta 8 and ‘Yes’ to installing with npm and start now. It should look like this:

-
Open a new terminal inside the client folder and install the dependencies. Here are the commands:
cd client npm install @electric-sql/react uuid npm install -D @types/uuid -
Then paste this code to App.tsx. This is our React app.
// src/App.tsx import React, { useState, useEffect } from 'react'; import { useShape } from '@electric-sql/react'; import { v4 as uuidv4 } from 'uuid'; function App() { const [title, setTitle] = useState(''); const [optimisticTodos, setOptimisticTodos] = useState<any[]>([]); // 1. THE READ PATH const { data: todos, isLoading } = useShape({ url: 'http://localhost:3000/v1/shape', params: { table: 'todos' }, }); const serverTodoIds = new Set(todos?.map((t: any) => t.id) || []); const pendingTodos = optimisticTodos.filter(t => !serverTodoIds.has(t.id)); const allTodos = [...(todos || []), ...pendingTodos]; // This listens for your computer to reconnect to Wi-Fi. // When it does, it quietly loops through any pending tasks and sends them to the server. useEffect(() => { const handleReconnection = async () => { console.log("Wi-Fi detected! Syncing waiting tasks..."); for (const task of pendingTodos) { try { await fetch('http://localhost:3001/todos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: task.id, title: task.title }), }); // Note: Once the server receives it, Electric will sync it down, // and the "Syncing..." badge will automatically disappear! } catch (error) { console.warn("Still failing to sync task:", task.title); } } }; // Tell the browser to listen for the "online" event window.addEventListener('online', handleReconnection); // Cleanup the listener when the component unmounts return () => window.removeEventListener('online', handleReconnection); }, [pendingTodos]); // 2. THE WRITE PATH const addTodo = async (e: React.FormEvent) => { e.preventDefault(); if (!title) return; const newId = uuidv4(); const newTask = { id: newId, title: title, completed: false, isPending: true }; setOptimisticTodos(prev => [...prev, newTask]); setTitle(''); try { await fetch('http://localhost:3001/todos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: newId, title: newTask.title }), }); } catch (error) { console.warn("You are offline! Task is waiting to sync."); } }; const toggleComplete = async (id: string, currentStatus: boolean) => { try { await fetch(`http://localhost:3001/todos/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ completed: !currentStatus }), }); } catch (error) { console.warn("Cannot update database while offline."); } }; if (isLoading) return <div style={{ textAlign: 'center', marginTop: '40px', color: '#0e3768', fontSize: '1.5rem' }}>Syncing local database...</div>; return ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', width: '100vw', backgroundColor: '#f1f2f6', fontFamily: '"Segoe UI", Roboto, Helvetica, Arial, sans-serif', padding: '20px', boxSizing: 'border-box', position: 'absolute', top: 0, left: 0 }}> <div style={{ width: '100%', maxWidth: '550px', backgroundColor: 'white', padding: '50px 40px', borderRadius: '16px', boxShadow: '0 10px 25px rgba(14, 55, 104, 0.1)' }}> <div style={{ textAlign: 'center', marginBottom: '40px' }}> <h1 style={{ margin: '0 0 8px 0', color: '#0e3768', fontSize: '2.4rem', fontWeight: '800' }}> Simple Task Tracker </h1> <p style={{ fontStyle: 'italic', color: '#555555', margin: 0, fontSize: '1.1rem', opacity: 0.8 }}> keeping tasks online even on offline mode </p> </div> <form onSubmit={addTodo} style={{ display: 'flex', gap: '12px', marginBottom: '30px' }}> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs to be done?" style={{ flex: 1, padding: '14px 16px', borderRadius: '8px', border: '2px solid #b0d8e3', fontSize: '1.1rem', color: '#0e3768', backgroundColor: '#f1f2f6', outline: 'none' }} /> <button type="submit" style={{ padding: '14px 28px', borderRadius: '8px', border: 'none', backgroundColor: '#0e3768', color: '#f1f2f6', cursor: 'pointer', fontSize: '1.1rem', fontWeight: 'bold', boxShadow: '0 4px 6px rgba(14, 55, 104, 0.2)' }}> Add </button> </form> <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> {allTodos.map((todo: any) => ( <li key={todo.id} style={{ padding: '16px 0', borderBottom: `2px solid #b0d8e3` }}> <label style={{ display: 'flex', gap: '16px', cursor: 'pointer', alignItems: 'center', opacity: todo.isPending ? 0.6 : 1 }}> <input type="checkbox" checked={todo.completed} onChange={() => toggleComplete(todo.id, todo.completed)} disabled={todo.isPending} style={{ transform: 'scale(1.4)', accentColor: '#0e3768', cursor: 'pointer' }} /> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none', fontSize: '1.2rem', color: todo.completed ? '#b0d8e3' : '#0e3768', fontWeight: '500', flex: 1 }}> {todo.title} </span> {todo.isPending && ( <span style={{ fontSize: '0.8rem', backgroundColor: '#efe897', color: '#555555', padding: '4px 10px', borderRadius: '12px', fontWeight: 'bold' }}> Syncing... </span> )} </label> </li> ))} {allTodos.length === 0 && ( <p style={{ textAlign: 'center', color: '#555555', fontStyle: 'italic', marginTop: '20px', fontSize: '1.1rem' }}> No tasks yet. Add one above! </p> )} </ul> </div> </div> ); } export default App;DON'T FORGET TO SAVE
-
Open a new terminal and run these commands:
cd client npm run dev Press CTRL and then click the local link highlighted in teal to visit the Simple Task Tracker

And voila! You have successfully integrated your first local-first sync with a web app using ElectricSQL! You can now keep track of your tasks even offline!

Guide 2: Integrating ElectricSQL into an Existing React App
Already have a working React frontend built with Vite or Next.js? Choose this path. We will skip the boilerplate and jump straight into the magic. You will learn how to replace your standard API loading spinners with Electric's zero-latency useShape hook, effortlessly upgrading your current web app into a fully offline-capable experience.
Step 0: The Pre-requisites (What You Need Before You Start)
Before dropping local-first syncing into your existing web application, ensure you have the following ready:
- Make sure Docker Desktop is installed and running on your machine. This application/tool is necessary for running the local database and sync engine. You may go to , download the version for your computer (Mac or Windows), double-click the installer, and follow the setup wizard.
- Make sure Node.js is also installed. This will be used for managing your backend and frontend packages.
- An existing React app with a working frontend (built with Next.js, etc.) where you want to add instant syncing.
- Basic PostgreSQL Knowledge
Step 1: Wiring Up the Sync Engine
ElectricSQL acts as a middleman between your PostgreSQL database and your frontend app. It requires a Postgres database with logical replication enabled. The easiest way to run both locally for development is using Docker Compose.
Create a new project folder. In this tutorial, the developers named the project folder as “Project Folder Name”.

Create a new “docker-compose.yml” file in your project's root folder. Make sure your computer isn't hiding file extensions. The file must end in .yml, not .yml.txt. Here's what the new YAML file should look like in your project’s root folder.

-
Open the newly created YAML file and add the configuration for Postgres and Electric to it. Feel free to modify the PostgreSQL database name, user, and password (don’t forget to also change the succeeding database name, user, and password later!).
services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: your_app_db POSTGRES_USER: postgres POSTGRES_PASSWORD: password ports: - '5432:5432' command: - -c - wal_level=logical # Crucial for Electric to detect changes electric: image: electricsql/electric:latest environment: - DATABASE_URL=postgresql://postgres:password@postgres:5432/your_app_db - ELECTRIC_INSECURE=true # For local development only ports: - '3000:3000' depends_on: - postgresDON'T FORGET TO SAVE
-
Open a new terminal on VS Code and run this command. Attached also is a successful result:
docker compose up -d A successful run of the previous command in step 4 creates a new container on Docker. To confirm this creation, open Docker Desktop and check whether the infrastructure was created. Here is an example of what a successful creation looks like:

Almost done with Step 1! Connect to your database (postgresql://postgres:password@localhost:5432/your_app_db) and create the tables your app needs.
Step 2: Implement the "Read Path" in Your React App
ElectricSQL exclusively handles the read path (PostgreSQL → Client) in order for data from your database directly into the browser's local memory in milliseconds.
-
Navigate to your frontend project folder and install the client:
# Bash npm install @electric-sql/react -
Replace your standard API fetching logic (like useEffect or React Query) with Electric's useShape hook. A "Shape" automatically subscribes to a table or query and keeps it perfectly synced with the server.
#TypeScript import { useShape } from '@electric-sql/react'; function YourComponent() { // ✅ The new way: Live-syncing database connection const { data: syncedData, isLoading } = useShape({ url: 'http://localhost:3000/v1/shape', params: { table: 'your_table_name' }, }); if (isLoading) return <div>Syncing local database...</div>; return ( <ul> {syncedData.map(item => <li key={item.id}>{item.title}</li>)} </ul> ); }
Step 3: Keep Your Existing Backend for the "Write Path"
- Because ElectricSQL streams data down to the client, you still need a standard backend API, such as Express, Django, Laravel, etc., to handle sending data up to the server. Your existing security and business logic do not need to change.
-
Whenever a user creates or updates an item in your app, send a standard HTTP request to your backend.
// Example Express backend route app.post('/api/items', async (req, res) => { const { id, name } = req.body; // Write the change to Postgres await db.query('INSERT INTO your_table_name (id, name) VALUES ($1, $2)', [id, name]); res.send({ success: true }); }); When Postgres updates, Electric automatically detects the change and pushes it to all connected frontend clients instantly.
Step 4: Add "Optimistic UI" for True Offline Support
To make your app feel instantaneous and work offline, you must hide the network delay of your Write Path. You do this by updating the user interface immediately, before the server confirms the save.
- Create a local temporary state (a "waiting room") for new items.
- When a user submits data, add it to the local state instantly.
-
Attempt the fetch request in the background.
#TypeScript // 1. Instant local update setPendingItems(prev => [...prev, newItem]); // 2. Background sync try { await fetch('http://localhost:3001/api/items', { method: 'POST', body: JSON.stringify(newItem), }); // Note: Once the server saves it, Electric streams the official row back down, // and you can remove the item from your `pendingItems` state! } catch (error) { console.warn("You are offline. The app will sync when reconnected.");
💻 Deployment 💻
Opening the App from Scratch
Make sure that your container in Docker Desktop is turned on.

-
Open a new terminal in VS Code and open the api folder start the index.js node. Run these commands individually and you will get a confirmation message if successful:
cd api node index.js -
Start the React frontend by opening a new terminal window and navigating into your frontend folder. Then, spin up your local React server by running these commands:
cd client npm run dev View your application by looking at your terminal output and Ctrl + Click (or Cmd + Click on Mac) the http://localhost:5173/ link. Your browser will open, and you will see your fully functioning, local-first web application.


And that's it! This is the entire process for opening the application.
📈Advantages of Using ElectricSQL📈
- ElectricSQL makes local syncing simpler and easier, allowing developers to focus more on app features. Since it has real time data synchronization users will always have the updated information, helping make collaboration more smooth sailing. Moreover, ElectricSQL uses rich CRDTs to automatically and seamlessly handle data conflicts.
📉Disadvantages of Using ElectricSQL 📉
- That said, ElectricSQL is not without its disadvantages— since it is designed to sync with PostgreSQL, using ElectricSQL limits the possible database systems you can use. There is also the risk of syncing large data which can lead to service instability. All in all, ElectricSQL is fairly new and has its fair share of downsides, but with continuous development it can improve in due time.
Here you can track its development.
Conclusion 🎉🎉
Congratulations! You have successfully built your local-first app!
Simply by following this guide, you have gained first-hand experience in local-first architecture with the use of ElectricSQL. If it suits your niche or piques your interest, you can continue by making more websites and apps where you focus on more complex features and implement ElectricSQL to further enhance your skills.
Who knows? Maybe you’ll be the one to find the optimal balance between local and cloud, after all—
“The future belongs to those who learn more skills and combine them in creative ways.” — Robert Greene.






Top comments (0)