TanStack DB with Next.js: Build a Reactive Client-Side Database in 2026

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

TanStack DB is the missing piece in modern React data layers. While TanStack Query brilliantly solves server state, it leaves a gap when you need fast cross-component reactivity, complex client-side filtering, or optimistic updates that feel truly instant. TanStack DB fills that gap with a reactive collection model, differential dataflow live queries, and first-class TypeScript support — all on top of the TanStack Query foundation you already know.

What You'll Build

A TaskFlow project management app demonstrating every core feature of TanStack DB:

  • Reactive collections synced with a REST backend
  • Live queries with joins, filters, and aggregations
  • Optimistic mutations that feel instantaneous
  • Cross-component reactivity without prop drilling
  • Differential dataflow that recomputes only what changed
  • Integration with Next.js App Router and Server Components

By the end, your UI will update in under one millisecond on any data change, even with thousands of items.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • Comfortable with React 19 and TypeScript
  • Familiarity with TanStack Query basics
  • Understanding of Next.js App Router
  • A code editor (VS Code recommended)

Why TanStack DB?

Traditional React state management forces a tradeoff. Server state libraries like TanStack Query handle fetching beautifully but treat each query as an isolated cache entry. Client state libraries like Zustand or Jotai are reactive but disconnected from your server. Local-first databases are powerful but heavyweight.

TanStack DB sits between them. It is a normalized, reactive store layered on top of TanStack Query collections, with a query engine powered by differential dataflow. That means when one row changes, only the views that depend on that row are recomputed — not the entire dataset.

The result feels like a tiny in-memory database with React bindings, while keeping your familiar TanStack Query backend.

Step 1: Project Setup

Create a new Next.js 15 project:

npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir
cd taskflow

Install TanStack DB and its peer dependencies:

npm install @tanstack/react-db @tanstack/db @tanstack/react-query @tanstack/query-core
npm install -D @tanstack/react-query-devtools

TanStack DB ships with adapters for several sync engines. We will use the Query collection adapter, which works with any REST or GraphQL backend.

Step 2: Define Your Schema

TanStack DB collections are strongly typed. Start by defining your domain types in src/lib/types.ts:

export type Project = {
  id: string;
  name: string;
  ownerId: string;
  createdAt: string;
};
 
export type Task = {
  id: string;
  projectId: string;
  title: string;
  status: "todo" | "doing" | "done";
  assigneeId: string | null;
  priority: number;
  createdAt: string;
};
 
export type User = {
  id: string;
  name: string;
  avatarUrl: string;
};

These types will flow through every query, mutation, and subscription, giving you end-to-end type safety.

Step 3: Create Your First Collection

A collection is the unit of reactivity in TanStack DB. Each collection is backed by a sync source and exposes reactive queries. Create src/db/collections.ts:

import { createCollection } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/db-collections";
import type { Project, Task, User } from "@/lib/types";
 
const API = "/api";
 
export const projectsCollection = createCollection(
  queryCollectionOptions<Project>({
    id: "projects",
    queryKey: ["projects"],
    queryFn: async () => {
      const res = await fetch(`${API}/projects`);
      if (!res.ok) throw new Error("Failed to load projects");
      return res.json();
    },
    getKey: (project) => project.id,
  })
);
 
export const tasksCollection = createCollection(
  queryCollectionOptions<Task>({
    id: "tasks",
    queryKey: ["tasks"],
    queryFn: async () => {
      const res = await fetch(`${API}/tasks`);
      if (!res.ok) throw new Error("Failed to load tasks");
      return res.json();
    },
    getKey: (task) => task.id,
  })
);
 
export const usersCollection = createCollection(
  queryCollectionOptions<User>({
    id: "users",
    queryKey: ["users"],
    queryFn: async () => {
      const res = await fetch(`${API}/users`);
      if (!res.ok) throw new Error("Failed to load users");
      return res.json();
    },
    getKey: (user) => user.id,
  })
);

Each collection automatically deduplicates fetches, caches results, and emits granular change events when rows are added, modified, or removed.

Step 4: Wire Up the Provider

TanStack DB shares a QueryClient with TanStack Query. Set up the provider in src/app/providers.tsx:

"use client";
 
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30_000,
            refetchOnWindowFocus: false,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Wrap the app in src/app/layout.tsx:

import { Providers } from "./providers";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Step 5: Live Queries with useLiveQuery

Now comes the magic. TanStack DB provides useLiveQuery, a reactive query hook that recomputes only when its dependencies change. Create src/components/TaskList.tsx:

"use client";
 
import { useLiveQuery, eq } from "@tanstack/react-db";
import { tasksCollection, usersCollection } from "@/db/collections";
 
type Props = {
  projectId: string;
};
 
export function TaskList({ projectId }: Props) {
  const { data: tasks } = useLiveQuery((q) =>
    q
      .from({ task: tasksCollection })
      .join(
        { user: usersCollection },
        ({ task, user }) => eq(task.assigneeId, user.id),
        "left"
      )
      .where(({ task }) => eq(task.projectId, projectId))
      .orderBy(({ task }) => task.priority, "desc")
      .select(({ task, user }) => ({
        id: task.id,
        title: task.title,
        status: task.status,
        priority: task.priority,
        assigneeName: user?.name ?? "Unassigned",
      }))
  );
 
  return (
    <ul className="space-y-2">
      {tasks.map((task) => (
        <li
          key={task.id}
          className="rounded-lg border border-slate-200 p-4"
        >
          <div className="flex items-center justify-between">
            <span className="font-medium">{task.title}</span>
            <span className="text-sm text-slate-500">
              {task.assigneeName}
            </span>
          </div>
          <div className="mt-1 text-xs uppercase text-slate-400">
            {task.status} · priority {task.priority}
          </div>
        </li>
      ))}
    </ul>
  );
}

This query joins three concepts: the tasks collection, the users collection, and a filter by project. Because it runs through a differential dataflow engine, when a single task title changes, only the row containing that task is recomputed, not the entire list.

Step 6: Optimistic Mutations

TanStack DB makes optimistic updates trivial. Create a mutation helper in src/db/mutations.ts:

import { createOptimisticAction } from "@tanstack/react-db";
import { tasksCollection } from "./collections";
import type { Task } from "@/lib/types";
 
export const updateTaskStatus = createOptimisticAction({
  onMutate: ({ taskId, status }: { taskId: string; status: Task["status"] }) => {
    tasksCollection.update(taskId, (draft) => {
      draft.status = status;
    });
  },
  mutationFn: async ({ taskId, status }) => {
    const res = await fetch(`/api/tasks/${taskId}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ status }),
    });
    if (!res.ok) throw new Error("Failed to update task");
    return res.json();
  },
});
 
export const createTask = createOptimisticAction({
  onMutate: (input: Omit<Task, "id" | "createdAt">) => {
    const id = crypto.randomUUID();
    tasksCollection.insert({
      ...input,
      id,
      createdAt: new Date().toISOString(),
    });
    return { tempId: id };
  },
  mutationFn: async (input) => {
    const res = await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(input),
    });
    if (!res.ok) throw new Error("Failed to create task");
    return res.json();
  },
  onSuccess: ({ tempId }, serverTask) => {
    tasksCollection.replace(tempId, serverTask);
  },
});

The onMutate block runs synchronously and updates the local collection. Every subscriber re-renders before the network request even starts. When the server responds, onSuccess reconciles the temporary record with the authoritative one.

If the mutation fails, TanStack DB automatically rolls back the optimistic change.

Step 7: Build the Drag-and-Drop Board

Combine queries and mutations into a Kanban board at src/components/TaskBoard.tsx:

"use client";
 
import { useLiveQuery, eq } from "@tanstack/react-db";
import { tasksCollection } from "@/db/collections";
import { updateTaskStatus } from "@/db/mutations";
import type { Task } from "@/lib/types";
 
const COLUMNS: Task["status"][] = ["todo", "doing", "done"];
 
export function TaskBoard({ projectId }: { projectId: string }) {
  const { data: tasksByStatus } = useLiveQuery((q) =>
    q
      .from({ task: tasksCollection })
      .where(({ task }) => eq(task.projectId, projectId))
      .groupBy(({ task }) => task.status)
      .select(({ task }) => ({
        status: task.status,
        items: task,
      }))
  );
 
  const handleDrop = (taskId: string, status: Task["status"]) => {
    updateTaskStatus.mutate({ taskId, status });
  };
 
  return (
    <div className="grid grid-cols-3 gap-4">
      {COLUMNS.map((status) => {
        const column = tasksByStatus.find((c) => c.status === status);
        return (
          <Column
            key={status}
            status={status}
            tasks={column?.items ?? []}
            onDrop={handleDrop}
          />
        );
      })}
    </div>
  );
}
 
function Column({
  status,
  tasks,
  onDrop,
}: {
  status: Task["status"];
  tasks: Task[];
  onDrop: (taskId: string, status: Task["status"]) => void;
}) {
  return (
    <div
      className="rounded-lg bg-slate-50 p-3"
      onDragOver={(e) => e.preventDefault()}
      onDrop={(e) => {
        const taskId = e.dataTransfer.getData("text/plain");
        onDrop(taskId, status);
      }}
    >
      <h3 className="mb-3 font-semibold uppercase">{status}</h3>
      {tasks.map((task) => (
        <div
          key={task.id}
          draggable
          onDragStart={(e) =>
            e.dataTransfer.setData("text/plain", task.id)
          }
          className="mb-2 cursor-grab rounded-md bg-white p-3 shadow-sm"
        >
          {task.title}
        </div>
      ))}
    </div>
  );
}

Drop a card from "todo" to "doing" and the entire board updates instantly. The optimistic mutation rewrites the local row, the differential engine re-runs the affected groupBy bucket, and React commits a precise patch.

Step 8: Cross-Component Reactivity

A single live query subscribes once but can power any number of components. Create a sidebar that shows aggregate counts at src/components/SidebarStats.tsx:

"use client";
 
import { useLiveQuery, count, eq } from "@tanstack/react-db";
import { tasksCollection } from "@/db/collections";
 
export function SidebarStats({ projectId }: { projectId: string }) {
  const { data: stats } = useLiveQuery((q) =>
    q
      .from({ task: tasksCollection })
      .where(({ task }) => eq(task.projectId, projectId))
      .groupBy(({ task }) => task.status)
      .select(({ task }) => ({
        status: task.status,
        total: count(task.id),
      }))
  );
 
  return (
    <aside className="space-y-3 p-4">
      {stats.map((row) => (
        <div key={row.status} className="flex justify-between">
          <span className="capitalize">{row.status}</span>
          <span className="font-semibold">{row.total}</span>
        </div>
      ))}
    </aside>
  );
}

The board and the sidebar both subscribe to the same underlying tasks collection. When you drop a task between columns, both components update from the same incremental change. There is no manual cache invalidation, no prop drilling, and no event bus.

Step 9: Integrating with Server Components

Next.js App Router lets you prefetch on the server. TanStack DB collections can be hydrated from server-rendered data. Create a server-side loader at src/app/projects/[id]/page.tsx:

import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/get-query-client";
import { TaskBoard } from "@/components/TaskBoard";
import { SidebarStats } from "@/components/SidebarStats";
 
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const queryClient = getQueryClient();
 
  await Promise.all([
    queryClient.prefetchQuery({
      queryKey: ["tasks"],
      queryFn: () => fetch(`${process.env.API_URL}/tasks`).then((r) => r.json()),
    }),
    queryClient.prefetchQuery({
      queryKey: ["users"],
      queryFn: () => fetch(`${process.env.API_URL}/users`).then((r) => r.json()),
    }),
  ]);
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <div className="grid grid-cols-[260px_1fr] gap-6 p-6">
        <SidebarStats projectId={id} />
        <TaskBoard projectId={id} />
      </div>
    </HydrationBoundary>
  );
}

Because TanStack DB sits on top of TanStack Query, the standard hydration boundary works without modification. Your collection arrives populated on first render with zero loading flicker.

Step 10: Custom Indexes for Speed

For collections with thousands of rows, you can add indexes to accelerate filters. Update the tasks collection definition:

export const tasksCollection = createCollection(
  queryCollectionOptions<Task>({
    id: "tasks",
    queryKey: ["tasks"],
    queryFn: loadTasks,
    getKey: (task) => task.id,
    indexes: [
      { id: "byProject", keyFn: (task) => task.projectId },
      { id: "byStatus", keyFn: (task) => task.status },
      { id: "byAssignee", keyFn: (task) => task.assigneeId ?? "" },
    ],
  })
);

The query planner uses these indexes automatically when your where clause matches. A filter by projectId over ten thousand tasks now runs in microseconds instead of a full scan.

Step 11: Real-Time Sync with Server-Sent Events

Pair TanStack DB with SSE for live multi-user collaboration. Create src/hooks/useTaskSync.ts:

"use client";
 
import { useEffect } from "react";
import { tasksCollection } from "@/db/collections";
import type { Task } from "@/lib/types";
 
type Event =
  | { type: "task.created"; task: Task }
  | { type: "task.updated"; task: Task }
  | { type: "task.deleted"; taskId: string };
 
export function useTaskSync(projectId: string) {
  useEffect(() => {
    const source = new EventSource(`/api/projects/${projectId}/stream`);
 
    source.onmessage = (event) => {
      const payload: Event = JSON.parse(event.data);
      switch (payload.type) {
        case "task.created":
          tasksCollection.upsert(payload.task);
          break;
        case "task.updated":
          tasksCollection.upsert(payload.task);
          break;
        case "task.deleted":
          tasksCollection.delete(payload.taskId);
          break;
      }
    };
 
    return () => source.close();
  }, [projectId]);
}

Mount this hook once per page. Every connected client now sees the same tasks update in real time, with the same differential efficiency as local mutations.

Testing Your Implementation

Run the dev server and open two browser windows side by side:

npm run dev

Drag a task in window one. The card jumps columns instantly in window one and arrives in window two within the SSE round trip. Open the React DevTools profiler and confirm that only the affected components re-render. Add a hundred more tasks and notice the absence of any noticeable slowdown.

Troubleshooting

Live query returns stale data after navigation. Make sure your provider wraps the entire layout, not a single page. A new QueryClient invalidates the collection cache.

Optimistic update flickers when the server responds. This usually means the server returns a record with a different shape than the optimistic insert. Align both shapes or use onSuccess to map the server payload before reconciliation.

Index does not seem to be used. Indexes only kick in for equality filters. A range filter or a function call on the indexed field falls back to a full scan.

TypeScript complains about null assignees in the join. Use a left join and mark the user side as optional in the select callback. The schema lets the type system know the user may be missing.

Next Steps

Conclusion

TanStack DB completes the picture that TanStack Query started. By layering a reactive collection model and a differential dataflow query engine on top of the cache you already trust, it eliminates the friction between server data and the views that depend on it. Your application stops reasoning about loading states and cache invalidation and starts reasoning about data — clean, normalized, and reactive end to end.

The library is still young, but the API surface is already focused and ergonomic. If you have ever built a dashboard or a collaborative tool in React and found yourself fighting with stale state, give TanStack DB a project and watch the rough edges disappear.


Want to read more tutorials? Check out our latest tutorial on Build a Progressive Web App (PWA) with Next.js App Router.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Building Local-First Collaborative Apps with Yjs and React

Learn how to build real-time collaborative applications that work offline using Yjs CRDTs and React. This tutorial covers conflict-free data synchronization, offline-first architecture, and building a shared document editor from scratch.

30 min read·