InstantDB is a real-time, multiplayer database for frontend applications — often described as the modern Firebase. Instead of wiring up a database, an API layer, a WebSocket server, and a client-side cache separately, InstantDB gives you a single client-side database that syncs instantly across every connected device, works offline, and applies optimistic updates by default.
What makes InstantDB stand out in 2026 is its relational graph model combined with instant sync. You define typed entities and links, query them with a tiny declarative syntax called InstaQL, and mutate them with InstaML transactions. Every change is reflected on screen immediately and propagates to all other clients in real time — no manual subscriptions, no cache invalidation.
In this tutorial you will build a real-time collaborative task board — a shared list where multiple users can add, complete, and delete tasks that sync instantly across browsers. You will also add magic-code authentication, see who else is online with presence, and lock down your data with permissions.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- A free InstantDB account — sign up at instantdb.com/dash
- Basic knowledge of React and TypeScript
- Familiarity with the Next.js App Router (layouts, client components, the
"use client"directive) - A code editor (VS Code recommended)
What You'll Build
A collaborative task board with:
- A typed schema with
taskslinked to$users - Real-time reads with InstaQL
- Optimistic create, update, and delete with InstaML
- Magic-code email authentication
- Live presence showing who is currently viewing the board
- Server-side permission rules so users only touch their own data
Let's get started.
Step 1: Create the Next.js Project
Spin up a fresh Next.js 15 app with the App Router and TypeScript:
npx create-next-app@latest instant-board --typescript --app --tailwind
cd instant-boardThen install the InstantDB React SDK and the CLI:
npm install @instantdb/react
npm install -D instant-cliThe @instantdb/react package ships the hooks, the transaction proxy, and the auth client you will use throughout this tutorial.
Step 2: Initialize InstantDB
Run the CLI to connect your local project to an InstantDB app. This will authenticate you in the browser and create the schema and permission files:
npx instant-cli@latest initWhen prompted, create a new app (or pick an existing one). The CLI writes two files to your project root:
instant.schema.ts— your typed data modelinstant.perms.ts— your access-control rules
It also prints your App ID. Add it to a .env.local file so it is never hardcoded:
# .env.local
NEXT_PUBLIC_INSTANT_APP_ID=your-app-id-hereThe NEXT_PUBLIC_ prefix is required so the App ID is available in the browser. The App ID is not a secret — your security comes from permission rules, which we configure in Step 8.
Step 3: Define Your Schema
Open instant.schema.ts and define a tasks entity plus a link to the built-in $users namespace. InstantDB provides $users automatically when you use authentication.
// instant.schema.ts
import { i } from "@instantdb/react";
const _schema = i.schema({
entities: {
$users: i.entity({
email: i.string().unique().indexed(),
}),
tasks: i.entity({
text: i.string(),
done: i.boolean(),
createdAt: i.date().indexed(),
}),
},
links: {
taskOwner: {
forward: { on: "tasks", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "tasks" },
},
},
});
// TypeScript helpers for end-to-end type safety
type _AppSchema = typeof _schema;
interface AppSchema extends _AppSchema {}
const schema: AppSchema = _schema;
export type { AppSchema };
export default schema;A few things to note:
i.string(),i.boolean(), andi.date()declare typed attributes..indexed()makes a field fast to filter and order by — always index fields you query against.- The
taskOwnerlink defines a one-to-many relationship: each task has one owner, and each user has many tasks.
Push the schema to InstantDB so the cloud knows about it:
npx instant-cli@latest push schemaStep 4: Initialize the Client
Create a single shared db instance. Because we are passing the schema, every query and transaction will be fully type-checked.
// lib/db.ts
import { init } from "@instantdb/react";
import schema from "../instant.schema";
export const db = init({
appId: process.env.NEXT_PUBLIC_INSTANT_APP_ID!,
schema,
});For Next.js apps that need server-side rendering of authenticated content, InstantDB also ships init from @instantdb/react/nextjs, which syncs the auth session through a first-party API route. For this client-rendered board, the standard @instantdb/react import is all we need.
Step 5: Add Magic-Code Authentication
InstantDB's simplest auth method is magic codes: the user enters an email, receives a six-digit code, and pastes it back. No passwords, no OAuth setup.
Create a login component:
// components/Login.tsx
"use client";
import { useState } from "react";
import { db } from "@/lib/db";
export function Login() {
const [sentEmail, setSentEmail] = useState("");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const sendCode = (e: React.FormEvent) => {
e.preventDefault();
setSentEmail(email);
db.auth.sendMagicCode({ email }).catch((err) => {
alert("Error: " + err.body?.message);
setSentEmail("");
});
};
const verifyCode = (e: React.FormEvent) => {
e.preventDefault();
db.auth.signInWithMagicCode({ email: sentEmail, code }).catch((err) => {
alert("Error: " + err.body?.message);
setCode("");
});
};
if (!sentEmail) {
return (
<form onSubmit={sendCode} className="flex flex-col gap-3 max-w-sm">
<h2 className="text-xl font-bold">Sign in</h2>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="border px-3 py-2 rounded"
required
/>
<button className="bg-blue-600 text-white px-3 py-2 rounded">
Send code
</button>
</form>
);
}
return (
<form onSubmit={verifyCode} className="flex flex-col gap-3 max-w-sm">
<h2 className="text-xl font-bold">Enter your code</h2>
<p>We emailed a code to {sentEmail}.</p>
<input
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
className="border px-3 py-2 rounded"
required
/>
<button className="bg-blue-600 text-white px-3 py-2 rounded">
Verify
</button>
</form>
);
}The two calls that do all the work are db.auth.sendMagicCode({ email }) and db.auth.signInWithMagicCode({ email, code }). On success, InstantDB stores the session and updates every component that reads auth state.
Step 6: Gate the App with useAuth
Use the db.useAuth() hook to decide whether to show the login screen or the board. It returns the loading state, the current user, and any error.
// app/page.tsx
"use client";
import { db } from "@/lib/db";
import { Login } from "@/components/Login";
import { Board } from "@/components/Board";
export default function Home() {
const { isLoading, user, error } = db.useAuth();
if (isLoading) return <div className="p-8">Loading...</div>;
if (error) return <div className="p-8">Auth error: {error.message}</div>;
return (
<main className="p-8 max-w-2xl mx-auto">
{user ? <Board user={user} /> : <Login />}
</main>
);
}Step 7: Read and Write in Real Time
Now the heart of the app. Create the Board component that reads tasks with InstaQL and mutates them with InstaML.
// components/Board.tsx
"use client";
import { useState } from "react";
import { id, type User } from "@instantdb/react";
import { db } from "@/lib/db";
export function Board({ user }: { user: User }) {
const [text, setText] = useState("");
// InstaQL: read the current user's tasks, ordered by creation time
const { isLoading, error, data } = db.useQuery({
tasks: {
$: {
where: { "owner.id": user.id },
order: { createdAt: "desc" },
},
},
});
const addTask = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) return;
const taskId = id();
// InstaML: create the task and link it to the user in one transaction
db.transact(
db.tx.tasks[taskId]
.update({ text, done: false, createdAt: Date.now() })
.link({ owner: user.id }),
);
setText("");
};
const toggle = (taskId: string, done: boolean) => {
db.transact(db.tx.tasks[taskId].update({ done: !done }));
};
const remove = (taskId: string) => {
db.transact(db.tx.tasks[taskId].delete());
};
if (isLoading) return <div>Loading tasks...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Task Board</h1>
<button onClick={() => db.auth.signOut()} className="text-sm underline">
Sign out
</button>
</div>
<form onSubmit={addTask} className="flex gap-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs doing?"
className="border px-3 py-2 rounded flex-1"
/>
<button className="bg-blue-600 text-white px-4 rounded">Add</button>
</form>
<ul className="flex flex-col gap-2">
{data.tasks.map((task) => (
<li
key={task.id}
className="flex items-center gap-3 border rounded px-3 py-2"
>
<input
type="checkbox"
checked={task.done}
onChange={() => toggle(task.id, task.done)}
/>
<span className={task.done ? "line-through opacity-60" : ""}>
{task.text}
</span>
<button
onClick={() => remove(task.id)}
className="ml-auto text-red-600 text-sm"
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}Three concepts power this component:
- InstaQL —
db.useQuery({ tasks: { ... } })is a live subscription. The$operator holds query options likewhereandorder. When any client changes a matching task, this component re-renders automatically. - InstaML —
db.transact(db.tx.tasks[taskId].update(...))describes a mutation. Thedb.txproxy follows the shapedb.tx.NAMESPACE[ID].ACTION(DATA), where actions includecreate,update,merge,delete,link, andunlink. id()— generates a fresh UUID for new entities so you can build the optimistic UI before the server responds.
Because InstantDB applies optimistic updates locally, a new task appears the instant you submit the form — even before the round trip completes.
Step 8: Lock It Down with Permissions
By default, an InstantDB app is open so you can prototype quickly. Before shipping, you must restrict access. Open instant.perms.ts and require that users only read and write their own tasks.
// instant.perms.ts
import type { InstantRules } from "@instantdb/react";
const rules = {
tasks: {
allow: {
view: "auth.id != null && auth.id == data.ref('owner.id')",
create: "auth.id != null && auth.id == newData.ref('owner.id')",
update: "auth.id != null && auth.id == data.ref('owner.id')",
delete: "auth.id != null && auth.id == data.ref('owner.id')",
},
},
} satisfies InstantRules;
export default rules;These rules use auth.id (the authenticated user's ID) and data.ref('owner.id') (the linked owner of the task) to ensure no one can read or modify tasks they do not own. Push the rules to the cloud:
npx instant-cli@latest push permsNever ship an app with the default open permissions. The App ID is public, so anyone could read or write your data until rules are in place. Test your rules by signing in as two different users and confirming each only sees their own tasks.
Step 9: Add Live Presence
Presence shows who is currently looking at the board in real time, without storing anything in the database. Create a room and publish each user's status.
// components/PresenceBar.tsx
"use client";
import { db } from "@/lib/db";
import type { User } from "@instantdb/react";
const room = db.room("board", "main");
export function PresenceBar({ user }: { user: User }) {
const { peers } = db.rooms.usePresence(room);
db.rooms.useSyncPresence(room, { email: user.email });
const others = Object.values(peers);
return (
<div className="text-sm text-gray-600">
{others.length === 0
? "You're the only one here"
: `${others.length} other ${others.length === 1 ? "person" : "people"} online: ` +
others.map((p) => p.email).join(", ")}
</div>
);
}Here db.room("board", "main") defines a shared ephemeral channel. useSyncPresence publishes the current user's data, and usePresence returns the peers currently in the room. Drop <PresenceBar user={user} /> into the Board and you instantly have a live "who's online" indicator. The same room primitive powers typing indicators, live cursors, and reactions.
Testing Your Implementation
- Run
npm run devand open http://localhost:3000. - Sign in with your email and the magic code.
- Add a few tasks — note they appear instantly thanks to optimistic updates.
- Open the app in a second browser window (or an incognito tab) and sign in with a different email. Confirm each account sees only its own tasks (your permission rules at work).
- Open the same account in two tabs and watch tasks sync in real time across both.
- Check the presence bar update as you open and close tabs.
Troubleshooting
- "Permission denied" on every query. Your rules likely reference a link that does not exist, or you forgot to push them. Run
npx instant-cli@latest push permsand confirm thetaskOwnerlink is in your schema. - Tasks do not appear after refresh. Make sure you pushed the schema (
push schema) and thatcreatedAtis indexed — ordering by an unindexed field fails. - Type errors on
db.useQuery. Confirmlib/db.tspasses theschemaargument toinit. Without it, queries are untyped. - Magic code never arrives. Check spam, and verify the App ID in
.env.localmatches the dashboard. Restart the dev server after editing env files.
Next Steps
- Add real-time cursors using the same
db.roomprimitive anduseTopicEffectfor ephemeral events. - Share boards between users by adding a
boardsentity with a many-to-many link to$users. - Go server-side with the
@instantdb/adminSDK to seed data or run trusted mutations from API routes. - Explore related tutorials on this site: Convex real-time apps, ElectricSQL sync, and local-first apps with Yjs.
Conclusion
In under 30 minutes you built a real-time, multiplayer, offline-capable task board with InstantDB and Next.js 15 — no API routes, no WebSocket plumbing, and no cache management. You defined a typed relational schema, read data with InstaQL, mutated it with InstaML, authenticated users with magic codes, secured everything with permission rules, and added live presence.
InstantDB's promise is that real-time and offline are not features you bolt on — they are the default. That shift lets frontend developers ship collaborative software with the same effort it once took to build a static CRUD app.