writing/tutorial/2026/06
TutorialJun 9, 2026·28 min read

Triplit and Next.js: Build a Local-First, Real-Time App in 2026

Learn how to build a fully offline-capable, real-time collaborative app with Triplit and Next.js. This hands-on tutorial covers schema design, optimistic mutations, relational queries, live React hooks, and access-control permissions.

Most web apps still treat the network as a hard dependency: tap a button, wait for a round trip, hope the server answers. Local-first software flips that model. Your data lives in the browser first, reads and writes resolve instantly against a local cache, and a background process syncs everything to the server and to other clients in real time. When the network drops, the app keeps working. When it comes back, changes reconcile automatically.

Triplit is an open-source, full-stack database built specifically for this model. It gives you a typed schema, a relational query engine that runs in the browser, optimistic writes, real-time sync, and fine-grained access-control permissions — all from a single TypeScript package. In this tutorial you will build a collaborative team task board with Next.js and Triplit, and watch changes propagate live across browser tabs even while offline.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • A working knowledge of React and Next.js App Router
  • Familiarity with TypeScript (the entire schema is typed)
  • A code editor (VS Code recommended)

No backend experience is required. Triplit ships its own sync server, and you will run it locally with one command.

What You'll Build

A real-time task board where:

  • Tasks belong to projects (a relational RelationMany link)
  • Creating, editing, completing, and deleting tasks happen optimistically — the UI updates before the server confirms
  • Changes sync live between open tabs and survive a full offline session
  • A permissions layer ensures users can only mutate their own tasks

By the end you will understand the four pillars of Triplit: schema, client, queries, and permissions.

Step 1: Project Setup

Start from a fresh Next.js app with the App Router and TypeScript:

npx create-next-app@latest triplit-board \
  --typescript --app --tailwind --eslint --src-dir=false
cd triplit-board

Install the Triplit packages. @triplit/client is the core engine, @triplit/react provides the hooks, and @triplit/cli runs the local dev server and manages your schema:

npm install @triplit/client @triplit/react
npm install --save-dev @triplit/cli

Initialize the Triplit project scaffolding. This creates a triplit/ folder with a schema.ts file:

npx triplit init

Step 2: Design the Schema

A Triplit schema is plain TypeScript. You declare collections, their fields, and the relationships between them using the Schema helper (conventionally imported as S). Open triplit/schema.ts and replace its contents:

// triplit/schema.ts
import { Schema as S } from '@triplit/client';
 
export const schema = S.Collections({
  projects: {
    schema: S.Schema({
      id: S.Id(),
      name: S.String(),
      color: S.String({ default: 'indigo' }),
      ownerId: S.String(),
      createdAt: S.Date({ default: S.Default.now() }),
    }),
    relationships: {
      // A project has many tasks whose projectId points back to it
      tasks: S.RelationMany('tasks', {
        where: [['projectId', '=', '$id']],
      }),
    },
  },
 
  tasks: {
    schema: S.Schema({
      id: S.Id(),
      title: S.String(),
      completed: S.Boolean({ default: false }),
      priority: S.String({ default: 'medium' }), // low | medium | high
      projectId: S.String(),
      authorId: S.String(),
      tags: S.Set(S.String()),
      createdAt: S.Date({ default: S.Default.now() }),
    }),
    relationships: {
      // The single project this task belongs to
      project: S.RelationById('projects', '$projectId'),
    },
  },
});

A few things worth noting:

  • S.Id() auto-generates a collision-resistant string id when you omit it on insert.
  • S.Set(S.String()) is a first-class set type — perfect for tags, and it merges cleanly across concurrent edits.
  • S.Date({ default: S.Default.now() }) stamps the creation time on the server-agnostic clock.
  • S.RelationMany and S.RelationById declare relationships you can later .Include() in a query, so the engine joins them client-side.

The $id and $projectId tokens are query variables: they refer to the current entity's fields when the relationship is resolved.

Step 3: Run the Local Sync Server

Triplit's dev server bundles the sync engine and an admin console. Start it:

npx triplit dev

This prints two important values: a serverUrl (default http://localhost:6543) and a development service token (a JWT). Copy them into .env.local. Because the client runs in the browser, the variables must be prefixed with NEXT_PUBLIC_:

# .env.local
NEXT_PUBLIC_TRIPLIT_SERVER_URL=http://localhost:6543
NEXT_PUBLIC_TRIPLIT_TOKEN=eyJhbGciOi...        # the dev token from `triplit dev`

Leave triplit dev running in its own terminal. It hot-reloads your schema as you edit it.

Step 4: Create the Client

The TriplitClient is the heart of your app. It owns the local cache, manages the websocket sync connection, and exposes the query and mutation API. Create a single shared instance:

// triplit/client.ts
import { TriplitClient } from '@triplit/client';
import { schema } from './schema';
 
export const triplit = new TriplitClient({
  schema,
  serverUrl: process.env.NEXT_PUBLIC_TRIPLIT_SERVER_URL,
  token: process.env.NEXT_PUBLIC_TRIPLIT_TOKEN,
  storage: 'indexeddb', // persist the cache so data survives reloads and offline
  autoConnect: true,
});

Setting storage: 'indexeddb' is what makes the app truly local-first: the cache lives in the browser's IndexedDB, so a page reload — or a full offline session — never loses data. The default 'memory' storage is fine for tests but evaporates on refresh.

Important: Create the client exactly once and import the same instance everywhere. Instantiating it inside a React component would spawn a new cache and sync connection on every render.

Step 5: Display Live Data with useQuery

Now the fun part. The useQuery hook subscribes a component to a query. When any matching data changes — locally or from a remote peer — the component re-renders automatically. No manual refetching, no cache invalidation.

Create a client component for the task list:

// app/components/TaskList.tsx
'use client';
 
import { triplit } from '@/triplit/client';
import { useQuery } from '@triplit/react';
 
export function TaskList({ projectId }: { projectId: string }) {
  // Build a query: tasks in this project, newest first
  const tasksQuery = triplit
    .query('tasks')
    .Where('projectId', '=', projectId)
    .Order('createdAt', 'DESC');
 
  const { results: tasks, fetching, error } = useQuery(triplit, tasksQuery);
 
  if (fetching) return <p className="text-slate-400">Loading tasks...</p>;
  if (error) return <p className="text-red-500">Error: {error.message}</p>;
 
  return (
    <ul className="space-y-2">
      {tasks?.map((task) => (
        <li key={task.id} className="flex items-center gap-3 rounded-lg border p-3">
          <input
            type="checkbox"
            checked={task.completed}
            onChange={() =>
              triplit.update('tasks', task.id, (t) => {
                t.completed = !t.completed;
              })
            }
          />
          <span className={task.completed ? 'line-through text-slate-400' : ''}>
            {task.title}
          </span>
          <span className="ml-auto text-xs uppercase text-slate-500">
            {task.priority}
          </span>
        </li>
      ))}
    </ul>
  );
}

The query builder is chainable and lazy — it describes what you want but does nothing until handed to a hook, fetch, or subscribe. The available filter operators include =, !=, <, >, like, in, and has (for sets).

Step 6: Insert and Update Optimistically

Writes in Triplit are optimistic by default. When you call insert or update, the change is applied to the local cache immediately and the UI reflects it in the same frame; the write is then queued and synced to the server in the background. If the server rejects it, Triplit rolls the local state back.

Add a form to create tasks:

// app/components/NewTaskForm.tsx
'use client';
 
import { useState } from 'react';
import { triplit } from '@/triplit/client';
 
export function NewTaskForm({
  projectId,
  authorId,
}: {
  projectId: string;
  authorId: string;
}) {
  const [title, setTitle] = useState('');
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!title.trim()) return;
 
    // Applied to the local cache instantly, synced in the background
    await triplit.insert('tasks', {
      title: title.trim(),
      projectId,
      authorId,
      priority: 'medium',
      tags: new Set<string>(),
    });
 
    setTitle('');
  }
 
  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        className="flex-1 rounded-lg border px-3 py-2"
        placeholder="What needs to be done?"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <button
        type="submit"
        disabled={!title.trim()}
        className="rounded-lg bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
      >
        Add
      </button>
    </form>
  );
}

Notice you never specify an idS.Id() generates one. You also never await a network round trip before clearing the input: the insert resolves against the local cache, so the new task appears instantly.

For multi-step changes that must succeed or fail together, wrap them in transact. The whole block is atomic — if any operation throws, the entire transaction rolls back:

// Move every task from one project to another, atomically
await triplit.transact(async (tx) => {
  const stale = await tx.fetch(
    triplit.query('tasks').Where('projectId', '=', 'archived')
  );
  for (const task of stale) {
    await tx.update('tasks', task.id, (t) => {
      t.projectId = 'inbox';
    });
  }
});

Because relationships are declared in the schema, you can pull related entities into a single query with .Include(). The engine resolves the join in the local cache — no extra request, no waterfall.

// app/components/ProjectBoard.tsx
'use client';
 
import { triplit } from '@/triplit/client';
import { useQuery } from '@triplit/react';
 
export function ProjectBoard() {
  // Fetch every project AND its tasks in one reactive query
  const query = triplit
    .query('projects')
    .Order('createdAt', 'ASC')
    .Include('tasks');
 
  const { results: projects } = useQuery(triplit, query);
 
  return (
    <div className="grid gap-6 md:grid-cols-2">
      {projects?.map((project) => (
        <section key={project.id} className="rounded-xl border p-4">
          <h2 className="mb-2 font-semibold">{project.name}</h2>
          <p className="text-sm text-slate-500">
            {project.tasks?.length ?? 0} tasks
          </p>
        </section>
      ))}
    </div>
  );
}

project.tasks is fully typed thanks to the schema, so your editor autocompletes task.title, task.completed, and the rest.

Step 8: Reflect Connection Status

A local-first app should tell the user whether it is syncing or running offline. The useConnectionStatus hook gives you a reactive value you can render directly:

// app/components/ConnectionBadge.tsx
'use client';
 
import { triplit } from '@/triplit/client';
import { useConnectionStatus } from '@triplit/react';
 
export function ConnectionBadge() {
  const status = useConnectionStatus(triplit);
  const online = status === 'OPEN';
 
  return (
    <span
      className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
        online ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
      }`}
    >
      <span
        className={`h-2 w-2 rounded-full ${online ? 'bg-emerald-500' : 'bg-amber-500'}`}
      />
      {online ? 'Synced' : 'Offline — changes saved locally'}
    </span>
  );
}

When offline, every mutation still lands in IndexedDB and queues for sync. Reconnect, and Triplit flushes the queue and merges remote changes — no conflict-resolution code on your side.

Step 9: Lock Things Down with Permissions

So far any client with the dev token can read and write everything. In production you attach permissions to each collection. They are filter clauses evaluated against the authenticated user's JWT claims, so the server can enforce access rules and never trust the client blindly.

Add a permissions block to the tasks collection. The $token.sub variable is the subject claim (the user id) from the JWT your auth provider issues:

// triplit/schema.ts (tasks collection, with permissions)
tasks: {
  schema: S.Schema({
    id: S.Id(),
    title: S.String(),
    completed: S.Boolean({ default: false }),
    priority: S.String({ default: 'medium' }),
    projectId: S.String(),
    authorId: S.String(),
    tags: S.Set(S.String()),
    createdAt: S.Date({ default: S.Default.now() }),
  }),
  relationships: {
    project: S.RelationById('projects', '$projectId'),
  },
  permissions: {
    authenticated: {
      // Everyone signed in can read tasks
      read: { filter: [true] },
      // You may only create tasks authored by yourself
      insert: { filter: [['authorId', '=', '$token.sub']] },
      // You may only edit or delete your own tasks
      update: { filter: [['authorId', '=', '$token.sub']] },
      delete: { filter: [['authorId', '=', '$token.sub']] },
    },
  },
},

In production you swap the dev token for a real JWT minted by your auth provider (Clerk, Supabase Auth, Auth.js, or a custom signer). Triplit verifies the signature against a configured public key and exposes its claims as $token.* inside permission filters. Read permissions are also enforced as queries — a client literally cannot subscribe to data it is not allowed to see.

Step 10: Wire It Together

Assemble a page. Since the Triplit hooks run in the browser, every consumer is a client component, but the page shell itself can stay a server component:

// app/page.tsx
import { ProjectBoard } from './components/ProjectBoard';
import { ConnectionBadge } from './components/ConnectionBadge';
 
export default function HomePage() {
  return (
    <main className="mx-auto max-w-3xl p-8">
      <header className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-bold">Team Board</h1>
        <ConnectionBadge />
      </header>
      <ProjectBoard />
    </main>
  );
}

Seed a project once from the browser console or a setup script so the board has something to show:

await triplit.insert('projects', {
  name: 'Launch Week',
  color: 'indigo',
  ownerId: 'user-1',
});

Testing Your Implementation

Verify the local-first behavior — this is the part you can't get from a traditional REST app:

  1. Real-time sync. Open the app in two browser tabs side by side. Add a task in one; it appears in the other within milliseconds, no refresh.
  2. Optimistic writes. Throttle your network to "Slow 3G" in DevTools and add a task. It still shows up instantly — the UI never waits for the server.
  3. Offline resilience. Open DevTools, go to the Network tab, and switch to Offline. Add, complete, and delete several tasks. The badge flips to "Offline — changes saved locally." Switch back to online: every change syncs to the other tab automatically.
  4. Persistence. While offline, hard-refresh the page. Your tasks are still there, served from IndexedDB.

If all four pass, you have a genuinely local-first application.

Troubleshooting

The client connects but no data syncs. Confirm triplit dev is still running and that NEXT_PUBLIC_TRIPLIT_SERVER_URL and NEXT_PUBLIC_TRIPLIT_TOKEN match its output exactly. A stale token from a previous triplit dev session is the most common cause.

Schema changes don't take effect. The dev server watches triplit/schema.ts, but the client caches the old schema in IndexedDB. Clear the site's IndexedDB storage in DevTools, or bump the storage to force a fresh cache during development.

Permission denied on insert. Your JWT's sub claim must equal the authorId you write. During local development the dev service token bypasses permissions; the error appears only once you switch to a real user token.

Hydration mismatch warnings. Triplit components read from the browser cache, which is empty during SSR. Mark every component that calls a Triplit hook with 'use client', and gate on the fetching flag for the first paint.

Next Steps

  • Add pagination with usePaginatedQuery or an infinite "load more" list with useInfiniteQuery — both require a .Limit() on the query.
  • Integrate real auth: see our guides on Clerk with Next.js or Auth.js v5 to mint the JWT Triplit verifies.
  • Deploy the sync server to Triplit Cloud or self-host it, then point serverUrl at the production endpoint.
  • Compare approaches: if you are weighing local-first options, read our tutorials on Zero by Rocicorp and ElectricSQL.

Conclusion

Triplit collapses a stack that usually takes a database, an API layer, a websocket server, a client cache, and an offline-sync library into one typed package. You defined a schema in TypeScript, queried it relationally from the browser, wrote optimistically, watched changes sync live across tabs, kept working offline, and enforced access control with declarative permission filters.

The local-first model is not just a performance trick — it changes what users can do. Apps stay responsive on flaky networks, survive dead zones, and feel instantaneous because reads and writes never leave the device on the critical path. With Triplit, that capability is a npm install and a schema away.