What if you could run a real SQL database in the browser — no backend, no server, and full support for joins, indexes, and transactions?

Thanks to SQLite compiled to WebAssembly (via sql.js), you can embed a full-featured, persistent relational DB in the browser, and use it directly from your React app. Ideal for offline-first apps, data visualization, form builders, and more.

Let’s build a local-first React app using SQLite over WASM.


Step 1: Install sql.js

Start with the WebAssembly version of SQLite:

npm install sql.js

Then import it in your app:

import initSqlJs from 'sql.js';

You’ll need to load the WASM binary (or use a CDN):


const SQL = await initSqlJs({
  locateFile: file => `https://sql.js.org/dist/${file}`
});

Step 2: Initialize the In-Browser SQLite Database

Create a database and run some schema + seed data:


const db = new SQL.Database();

db.run(`
  CREATE TABLE todos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    text TEXT NOT NULL,
    completed BOOLEAN DEFAULT 0
  );
`);

db.run(`
  INSERT INTO todos (text, completed)
  VALUES ('Write blog post', 0), ('Ship to production', 1);
`);

This database lives entirely in memory — but can be exported for persistence.


Step 3: Query Data from React

Now use SQLite as your actual data store in React. Here's a basic query function:


function getTodos(db) {
  const results = db.exec("SELECT * FROM todos");
  if (!results.length) return [];
  
  const [cols, ...rows] = results[0].values;
  return results[0].values.map(row => {
    return Object.fromEntries(
      results[0].columns.map((col, i) => [col, row[i]])
    );
  });
}

Render in a component:


const [todos, setTodos] = useState([]);

useEffect(() => {
  setTodos(getTodos(db));
}, []);

Step 4: Mutate State with SQL Writes

Use SQL to modify app state — like this insert function:


function addTodo(db, text) {
  db.run("INSERT INTO todos (text, completed) VALUES (?, ?)", [text, 0]);
}

React handles UI; SQLite handles the data layer. You’ve got full transactions, queries, and mutations, all client-side.


Step 5: Persist to localStorage or IndexedDB

Save the database to persist it between sessions:


const binaryArray = db.export();
const base64 = btoa(
  binaryArray.reduce((data, byte) => data + String.fromCharCode(byte), '')
);
localStorage.setItem("db_backup", base64);

And load it later:


const saved = localStorage.getItem("db_backup");
if (saved) {
  const binary = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
  const db = new SQL.Database(binary);
}

Pros:

  • 🗃 Full SQL support in the browser (joins, indexes, FKs, etc.)
  • 📴 Works completely offline
  • ⚡ Fast — WASM is native-speed
  • 💾 Persistable to localStorage/IndexedDB
  • 🧠 Ideal for local dashboards, forms, and prototyping

⚠️ Cons:

  • 📦 sql.js is ~600kb compressed (WASM size)
  • 🧪 No built-in sync or replication (must DIY)
  • ❌ Not suited for large-scale multi-user apps
  • 🔐 No built-in auth — everything runs on the client

Summary

You don’t need a backend to build a fully functional, SQL-powered application. With SQLite compiled to WebAssembly, you can run robust relational queries right in the browser — making it perfect for offline apps, portable tools, or backend-less prototypes. Add persistence via localStorage or IndexedDB and you’ve got a fast, private, and portable database inside your app.

This opens the door for truly local-first software — and you can start building it today.

For a much more extensive guide on getting the most out of React portals, check out my full 24-page PDF file on Gumroad. It's available for just $10:

Using React Portals Like a Pro.


If this was helpful, you can support me here: Buy Me a Coffee