Get started with local-first

This guide will help you get started with the Evolu local-first platform.

Requirements: TypeScript 7 or newer with exactOptionalPropertyTypes enabled.

Choose a platform
This selection will apply to all code examples on this page.

Installation

Evolu offers SDKs for a variety of frameworks, including React, Svelte, React Native, Expo, and others. Below, you can see how to install the SDKs for each framework.

npm install @evolu/common @evolu/react @evolu/react-web @evolu/web

Define schema

First, define your app database schema—tables, columns, and types.

Evolu uses Type for data modeling. Instead of plain JS types like string or number, we recommend using branded types to enforce domain rules.

import * as Evolu from "@evolu/common";

// Primary keys are branded types, preventing accidental use of IDs across
// different tables (e.g., a TodoId can't be used where a UserId is expected).
const TodoId = Evolu.id("Todo");
type TodoId = typeof TodoId.Output;

// Schema defines the database structure and mutation/query types.
// Validate untrusted input before insert/update/upsert.
const Schema = {
  todo: {
    id: TodoId,
    // Branded type ensuring titles are non-empty and ≤100 chars.
    title: Evolu.NonEmptyTrimmedString100,
    // SQLite doesn't support the boolean type; it uses 0 and 1 instead.
    isCompleted: Evolu.nullOr(Evolu.SqliteBoolean),
  },
};

Evolu automatically adds system columns: createdAt, updatedAt, isDeleted, and ownerId.

Create Evolu

After defining the schema, create an Evolu instance for your environment. Every instance needs an appOwner. Create one on the first run and persist its secret material securely, for example in Expo SecureStore or WebAuthn-backed storage, then restore the same owner on later runs:

import {
  createAppOwner,
  createOwnerSecret,
  createRandomBytes,
} from "@evolu/common";

// First run only. Persist the secret and restore the owner on later runs.
const appOwner = createAppOwner(
  createOwnerSecret({ randomBytes: createRandomBytes() }),
);

The following snippets continue from the schema and appOwner above.

Create one shared Run for your application's Evolu instances. Omit transports to use the default relay, provide your own relay URLs, or pass transports: [] to disable synchronization.

import { AppName, createEvolu } from "@evolu/common";
import { createEvoluBinding } from "@evolu/react";
import { createEvoluDeps } from "@evolu/react-web";
import { createRun } from "@evolu/web";
import { Suspense, use, type ReactNode } from "react";

const run = createRun(createEvoluDeps());
const evoluPromise = run.ok(
  createEvolu(Schema, {
    appName: AppName.orThrow("your-app-name"),
    appOwner,
  }),
);

const { EvoluContext, useEvolu, useQuery } = createEvoluBinding<typeof Schema>();

const EvoluProvider = ({ children }: { children: ReactNode }) => (
  <EvoluContext value={use(evoluPromise)}>{children}</EvoluContext>
);

export const App = ({ children }: { children: ReactNode }) => (
  <Suspense fallback={null}>
    <EvoluProvider>{children}</EvoluProvider>
  </Suspense>
);

// Call useEvolu() and useQuery() in components inside EvoluProvider.

Mutate data

const { insert, update } = useEvolu();

const { id } = insert("todo", {
  title: Evolu.NonEmptyTrimmedString100.orThrow("New Todo"),
  isCompleted: Evolu.sqliteFalse,
});

update("todo", { id, isCompleted: Evolu.sqliteTrue });

Query data

Evolu uses type-safe TypeScript SQL query builder Kysely, so autocompletion works out-of-the-box.

Let's start with a simple Query.

const createQuery = Evolu.createQueryBuilder(Schema);
const allTodos = createQuery((db) => db.selectFrom("todo").selectAll());

Once we have a query, we can load or subscribe to it.

// Inside a component, use the useQuery binding created above.
const todos = useQuery(allTodos);

Delete data

To delete a row, set isDeleted to sqliteTrue (1). Synced tables use soft deletes. Tables prefixed with _ delete the row permanently and have no sync history. In the examples below, todoId is the ID of the row to delete.

const { update } = useEvolu();

// Mark a todo as deleted
update("todo", { id: todoId, isDeleted: Evolu.sqliteTrue });

When querying, filter out deleted rows:

const activeTodos = createQuery((db) =>
  db
    .selectFrom("todo")
    .selectAll()
    // Filter out deleted rows
    .where("isDeleted", "is not", Evolu.sqliteTrue)
    .orderBy("createdAt"),
);

Synced tables retain deleted data to support merging across devices and time travel. This is essential for local-first systems where devices sync asynchronously. See Time Travel to learn how to recover deleted data.

Protect data

Privacy is essential for Evolu, so all data are encrypted with an encryption key derived from a cryptographically strong secret (which can be represented as a mnemonic) or provided by an external hardware device.

evolu.appOwner is the owner passed to createEvolu; it is available synchronously. For the owner created from a secret in this guide, mnemonic can be shown in your recovery UI.

// Inside a component, use the binding created above.
const owner = useEvolu().appOwner;
const mnemonic = owner.mnemonic;

Purge data

Evolu.deleteDatabase and Evolu.deleteOwner are declared but not implemented yet. Both currently throw. Implementation is planned soon.

deleteDatabase is intended to remove an instance's local database. deleteOwner is intended to remove an owner's local rows and history and stop using that owner across tabs. Neither currently provides a working purge operation. The Evolu 7 resetAppOwner method is no longer available.

Restore data

The Evolu 7 restoreAppOwner method is no longer available. To restore synced data, validate the user's mnemonic with Mnemonic, convert it with mnemonicToOwnerSecret, and pass the resulting secret to createAppOwner. Then pass that owner to createEvolu and connect to the relays holding its data.

Use the original appName to reopen the same local database on a device that already has it. Your app currently manages owner persistence, instance switching, and UI updates. Built-in account management is still being developed. Rows in _-prefixed tables cannot be restored from a relay because they never sync.

To learn more about Evolu, explore our playgrounds and examples.