Convex in Your App
Add a schema, write mutations, and read and write tasks from a Next.js page

Buzz presents
lets build a little to-do app with Convex!! add tasks, check them off, and watch every open tab update all by itself.lets build a little to-do app with Convex!! add tasks, check them off, and watch every open tab update all by itself.

Last lesson your app could read tasks from Convex. Cool start. Now we'll turn it into a real lil to-do app that adds tasks and checks them off, with every open browser tab staying in sync the whole time.
Where We're Starting#
This lesson picks up right where the Next.js quickstart left off. You should already have these pieces.
- A
taskstable with three sample tasks, each withtextandisCompleted - A
convex/tasks.tsfile with agetquery ConvexClientProviderwrapping your app inapp/layout.tsxnpx convex devrunning in one terminal andnpm run devin another
We'll touch three files. A new convex/schema.ts, the existing convex/tasks.ts and app/page.tsx.
Step by Step#
Step 1. Add a Schema#
A schema describes your tables and what each document should look like. Convex runs fine without one. Add a schema though, and Convex checks every document you save while your editor learns the exact shape of your data.
Create a new file called convex/schema.ts.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
}),
});Here's what's happening.
defineSchemadescribes your whole databasedefineTabledescribes one table, here calledtasksvis the validator builder.v.string()means "this has to be text" andv.boolean()means "this has to be true or false"
You don't list _id or _creationTime. Convex tacks those onto every document automatically.
When you save, npx convex dev pushes the schema to your dev deployment and Convex starts enforcing it. The fields match the sample data you imported, so nothing breaks.
Step 2. Write a Mutation to Add Tasks#
Open convex/tasks.ts. Update the import at the top, add the v import and drop a createTask mutation below your existing get query.
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const get = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
// Create a new task with the given text
export const createTask = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
const newTaskId = await ctx.db.insert("tasks", {
text: args.text,
isCompleted: false,
});
return newTaskId;
},
});Let's pull it apart.
mutationtells Convex this function is allowed to write to the databaseargssays the function takes one argument calledtext, and it has to be a string. Convex checks that EVERY time the function gets calledctx.db.insert("tasks", ...)adds a new document to thetaskstable- It hands back the new task's ID, which Convex made for you
Every new task starts out with isCompleted: false, since our schema says that field is required.
Step 3. Write a Mutation to Check Off Tasks#
Add one more mutation at the bottom of the same file.
export const setTaskCompleted = mutation({
args: { taskId: v.id("tasks"), completed: v.boolean() },
handler: async (ctx, { taskId, completed }) => {
await ctx.db.patch("tasks", taskId, { isCompleted: completed });
},
});Two new things in there.
v.id("tasks")means this argument has to be the ID of a document in thetaskstable, so any old string won't cut itctx.db.patchupdates only the fields you pass in. Here it flipsisCompletedand leavestextalone
Step 4. Use Your Functions in the Page#
Now swap out app/page.tsx for a page that lists tasks, adds new ones and checks them off.
"use client";
import { useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export default function Home() {
const tasks = useQuery(api.tasks.get);
const createTask = useMutation(api.tasks.createTask);
const setTaskCompleted = useMutation(api.tasks.setTaskCompleted);
const [newTaskText, setNewTaskText] = useState("");
return (
<main className="flex min-h-screen flex-col items-center gap-4 p-24">
<form
onSubmit={async (e) => {
e.preventDefault();
await createTask({ text: newTaskText });
setNewTaskText("");
}}
>
<input
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
placeholder="Add a task"
/>
<button type="submit" disabled={!newTaskText}>
Add
</button>
</form>
{tasks === undefined
? "Loading..."
: tasks.map((task) => (
<label key={task._id}>
<input
type="checkbox"
checked={task.isCompleted}
onChange={() =>
setTaskCompleted({
taskId: task._id,
completed: !task.isCompleted,
})
}
/>
{task.text}
</label>
))}
</main>
);
}How the Page Works#
Reading with useQuery#
useQuery(api.tasks.get) subscribes your page to the get query. It returns undefined while the data first loads, which is why you see "Loading..." until the tasks show up. After that, it holds your list of tasks.
Writing with useMutation#
useMutation(api.tasks.createTask) doesn't run anything right away. It hands you back a function, and you call that function with your arguments when the user does something, like submitting the form.
await createTask({ text: newTaskText });The Generated api Object#
Check out api.tasks.createTask and api.tasks.setTaskCompleted. The name comes from the file (tasks.ts) plus the export name. npx convex dev generates this api object for you in the convex/_generated folder, so your editor can autocomplete function names and warn you when you pass the wrong arguments.
See It Update Live#

Here's the fun part. Look closely... there's no code anywhere in the page that refreshes the list after you add or check off a task.
When createTask saves a new document, Convex knows the get query read from the tasks table. It reruns the query and pushes the new list to every page that's subscribed, and your component re-renders with the new task.
Try it out.
- Open http://localhost:3000 in two browser windows side by side
- Add a task in one window
- Watch it show up in the other window right away
- Check off a task and watch both windows update
Where Do Actions Fit?#
Our to-do app only needed queries and mutations, since it only talks to the Convex database. Later on, if you want to send an email or call a payment service, that's what actions are for. Actions can call outside services and then call a mutation to save the result. The Convex actions docs walk through writing one.
TL;DR#
- A schema in
convex/schema.tsusesdefineSchema,defineTableandvvalidators to describe your data - Mutations use
ctx.db.insertto add documents andctx.db.patchto update them argswithvvalidators checks every value sent to your functionsuseQueryreads data and stays subscribed, anduseMutationgives you a function to call when you want to write- You never refresh the list by hand, because Convex reruns your query and updates every open page
What's Next?#
You've now seen two VERY different databases. Supabase gives you SQL on Postgres, and Convex gives you documents plus TypeScript functions. Next, start thinking about which one fits the app you actually want to build.
This lesson ends with a short activity.
