Real-Time Collaboration Made Easy: Build a Shared To-Do List with Convex.dev
Learn how to build a real-time, shared to-do list application using Convex.dev and Next.js. Discover how to skip the WebSockets and cache invalidation while getting automatic sync and end-to-end type safety.
The dream of modern web development is seamless real-time collaboration. We all want our apps to feel like Google Docs or Figma—where users see updates instantly, and the interface feels alive.
But the reality of building it? Often a nightmare.
Traditionally, "real-time" meant managing WebSocket connections, handling complex pub/sub channels, wrestling with state synchronization, and writing endless boilerplate to invalidate client-side caches. It turns a simple feature into a weeks-long infrastructure project.
Convex.dev changes the paradigm completely.
With Convex, real-time isn't a special feature you add on later; it's the default state of the database. In this guide, we are going to build a fully functional, shared to-do list where multiple users can add, check off, and delete tasks, seeing each other's actions instantly.
Best of all? We won't write a single line of WebSocket code.
Why Convex for Real-Time Collaboration?
Before we dive into the code, it's important to understand why this build is going to be different from your typical backend experience.
Automatic Reactivity
Convex functions like a reactive engine. When you write a query in Convex (to fetch tasks, for example), your React components subscribe to that query. If the underlying data in the database changes—say, someone adds a new task—Convex automatically pushes the new result to your client, and your component re-renders. You don't ask for updates; they just happen.
No More Cache Invalidation
Because Convex handles the subscription connection, you never have to manually invalidate a cache (like queryClient.invalidateQueries in React Query). The database tells the client when the data is stale. This eliminates an entire category of common synchronization bugs.
End-to-End Type Safety
We'll be using TypeScript. Because Convex generates types based on your backend schema, your frontend code will know exactly what data to expect. If you change a field name in your database, your frontend build will fail immediately, saving you from runtime errors.
Project Setup: Your Collaborative Foundation
Let's get our hands dirty. We'll use Next.js for the frontend and Convex for the backend.
Prerequisites
- Node.js (LTS)
- npm, yarn, or pnpm
- Basic familiarity with React and Next.js
Initialize the Project
First, create a fresh Next.js project. We'll use the App Router and TypeScript.
npx create-next-app@latest my-todo-app --typescript --eslint --tailwind --no-src-dir
cd my-todo-app
Next, initialize Convex within your project.
npm install convex
npx convex init
This command will prompt you to log in, create a project in your Convex dashboard, and it will generate a convex/ directory in your project root. This folder is where all your backend logic lives.
Finally, set up the ConvexClientProvider. In Next.js App Router, we need to wrap our application in a client component to provide the Convex context.
Create a file app/ConvexClientProvider.tsx:
"use client";
import { ReactNode } from "react";
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
Then, wrap your app/layout.tsx body content:
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import ConvexClientProvider from "./ConvexClientProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Convex Shared To-Do",
description: "Real-time collaboration with Convex",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
Building the Convex Backend: Queries & Mutations
To make a to-do list collaborative, we need to organize tasks into "Boards." This allows different groups of people to have their own private lists just by sharing a URL.
1. Define the Schema
Open convex/schema.ts. We'll define two tables: boards and tasks.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
// A simple table to hold board existence
boards: defineTable({
name: v.string(),
}),
// The tasks table, linked to a specific board
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
boardId: v.id("boards"),
}).index("by_boardId", ["boardId"]), // Index for fast fetching by board
});
The .index on tasks is crucial. It ensures that when we ask for "all tasks for Board A," the database doesn't have to scan every single task in existence to find them.
2. Create Board Functions
Create a new file convex/boards.ts. We need a way to create a board and fetch its details.
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
export const create = mutation({
args: { name: v.string() },
handler: async (ctx, args) => {
const boardId = await ctx.db.insert("boards", { name: args.name });
return boardId;
},
});
export const get = query({
args: { id: v.id("boards") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
3. Task Management Functions
Now, create convex/tasks.ts. This is where the magic happens. We need to Add, Get, Toggle, and Delete tasks.
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
// GET ALL TASKS FOR A BOARD
export const get = query({
args: { boardId: v.id("boards") },
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_boardId", (q) => q.eq("boardId", args.boardId))
.collect();
},
});
// ADD A NEW TASK
export const add = mutation({
args: {
text: v.string(),
boardId: v.id("boards")
},
handler: async (ctx, args) => {
await ctx.db.insert("tasks", {
text: args.text,
isCompleted: false,
boardId: args.boardId,
});
},
});
// TOGGLE COMPLETION STATUS
export const toggle = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
const task = await ctx.db.get(args.id);
if (!task) return; // Handle edge case where task might be deleted
await ctx.db.patch(args.id, { isCompleted: !task.isCompleted });
},
});
// DELETE TASK
export const remove = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
That is our entire backend. No controllers, no SQL migrations, no WebSocket handlers. Just pure logic.
Crafting the Frontend
Now we need a UI to interact with our shiny new backend.
1. Board Creation Page
In app/page.tsx, we'll create a simple button to generate a new board and redirect the user to it.
"use client";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useRouter } from "next/navigation";
export default function Home() {
const createBoard = useMutation(api.boards.create);
const router = useRouter();
const handleCreateBoard = async () => {
const boardId = await createBoard({ name: "My New List" });
router.push(`/board/${boardId}`);
};
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold mb-8">Collaborative To-Do List</h1>
<button
onClick={handleCreateBoard}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-colors"
>
Create New Shared Board
</button>
</main>
);
}
2. The Shared To-Do List Page
This is the core of the collaboration. Create app/board/[id]/page.tsx. This page will use useQuery to listen for real-time updates.
"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { Id } from "../../../convex/_generated/dataModel";
import { useState } from "react";
export default function BoardPage({ params }: { params: { id: string } }) {
// We cast the string ID from the URL to a Convex ID
const boardId = params.id as Id<"boards">;
// REAL-TIME DATA FETCHING
// This variable will automatically update whenever the database changes!
const tasks = useQuery(api.tasks.get, { boardId });
const board = useQuery(api.boards.get, { id: boardId });
// Mutations
const addTask = useMutation(api.tasks.add);
const toggleTask = useMutation(api.tasks.toggle);
const deleteTask = useMutation(api.tasks.remove);
const [newTaskText, setNewTaskText] = useState("");
const handleAddTask = (e: React.FormEvent) => {
e.preventDefault();
if (!newTaskText.trim()) return;
addTask({ boardId, text: newTaskText });
setNewTaskText("");
};
// Loading state
if (tasks === undefined || board === undefined) {
return <div className="p-24 text-center">Loading board...</div>;
}
// 404 state
if (board === null) {
return <div className="p-24 text-center">Board not found</div>;
}
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">{board.name}</h1>
{/* ADD TASK FORM */}
<form onSubmit={handleAddTask} className="flex gap-2 mb-8">
<input
type="text"
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
placeholder="What needs to be done?"
className="flex-1 p-2 border border-gray-300 rounded text-black"
/>
<button
type="submit"
className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
>
Add
</button>
</form>
{/* TASK LIST */}
<div className="space-y-3">
{tasks.map((task) => (
<div
key={task._id}
className="flex items-center justify-between p-4 bg-gray-50 rounded shadow-sm border"
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={task.isCompleted}
onChange={() => toggleTask({ id: task._id })}
className="w-5 h-5 text-blue-600 cursor-pointer"
/>
<span className={`text-lg text-black ${task.isCompleted ? "line-through text-gray-400" : ""}`}>
{task.text}
</span>
</div>
<button
onClick={() => deleteTask({ id: task._id })}
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</div>
))}
{tasks.length === 0 && (
<p className="text-gray-500 text-center italic">No tasks yet. Add one above!</p>
)}
</div>
</main>
);
}
Making it Truly "Shared": Demonstrating Collaboration
Here is the moment of truth.
- Run your app with
npm run dev. - Also ensure your Convex backend is running with
npx convex dev(in a separate terminal). - Open
http://localhost:3000in your browser. - Click "Create New Shared Board."
- Copy the URL (it should look like
http://localhost:3000/board/kh7...).
Now, open a Second Browser Window (or an Incognito window, or pull it up on your phone connected to the same WiFi) and paste that URL.
Type a task in Window A and hit Enter. It appears instantly in Window B. Check the box in Window B. It crosses out instantly in Window A.
You didn't write a WebSocket handler. You didn't set up a Redis pub/sub. You didn't configure a useEffect to poll for changes. You simply asked Convex for the data, and Convex ensured you always had the latest version.
Deployment
Deploying this is just as easy as building it. Convex pairs perfectly with Vercel.
- Push your code to a GitHub repository.
- Import the project into Vercel.
- Vercel will detect that you are using Convex and ask for your Convex Production Deployment Key.
- You can find this in your Convex Dashboard under Settings > Deployment.
- Add it as an Environment Variable (
CONVEX_DEPLOY_KEY) in Vercel.
Your frontend and backend will deploy together, giving you a live, production-ready real-time application in minutes.
Conclusion
We just built a "complex" real-time collaboration tool in roughly 15 minutes.
The power of Convex lies in what you don't have to do. By removing the need to manage connections, cache states, and type definitions manually, you free yourself to focus purely on the product experience.
What we built here is a foundation. From here, you could add:
- User Authentication: Secure your boards so only specific users can see them.
- Real-time Presence: Show "User X is typing..." indicators.
- Complex Data: Add drag-and-drop reordering or sub-tasks.
With Convex, the infrastructure is already solved. The only limit is what you decide to build next.
Resources
Ready to visualize your Convex backend?
Load your Convex folder and explore your schema with interactive tables, relationships, and chat with BackendBOT for intelligent insights.
Related Articles
TypeScript All the Way: Achieving Seamless End-to-End Type Safety with Convex.dev
Dive deep into Convex.dev's unparalleled end-to-end type safety. Learn how your database schema seamlessly types your backend queries, mutations, and frontend code, eliminating runtime errors.
Convex for Frontend Devs: Stop Managing Servers and Start Shipping Features
Stop letting infrastructure slow you down. Learn how Convex allows frontend developers to own the full stack without the ops headaches, enabling you to ship faster than ever before.
Say Goodbye to REST: Rethinking Backends with Convex’s Reactive Model
For modern, interactive apps, the request-response cycle is a bottleneck. Learn why it's time to replace REST with Convex's reactive model.