Schema Design — Two Related Tables

Our kanban board manages one flat list of tasks. A planner needs projects too, and each project has its own tasks. So we need two tables that are related to each other.

The Data Model

Here is what we need:

  • A projects table: each project has a name and a description
  • A tasks table: each task belongs to a specific project

The relationship between them: every task has a projectId field that points to a document in the projects table. This is how you model relationships in Convex: with ID references.

projects                          tasks
┌──────────────────────┐         ┌──────────────────────────────┐
│ _id: Id<"projects">  │◀────────│ projectId: Id<"projects">    │
│ name: string         │         │ _id: Id<"tasks">             │
│ description: string  │         │ title: string                │
└──────────────────────┘         │ description: string          │
                                 │ status: "todo" | ...         │
                                 └──────────────────────────────┘

This is a common pattern in database design, especially in relational databases. Convex is not a relational database, but it uses the same pattern.

Update the Schema

Update convex/schema.ts:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  projects: defineTable({
    name: v.string(),
    description: v.optional(v.string()),
  }),
  tasks: defineTable({
    projectId: v.id("projects"),
    title: v.string(),
    description: v.string(),
    status: v.union(
      v.literal("todo"),
      v.literal("in-progress"),
      v.literal("done"),
    ),
  }),
});

What changed:

  • New projects table: with name and (optional) description fields. Like all Convex documents, each project automatically gets _id and _creationTime.

  • projectId: v.id("projects"): this is the relationship. Every task now belongs to a project. The v.id("projects") validator ensures the value is a valid document ID.

This is called a one-to-many relationship: one project has many tasks, and each task belongs to one project.

Update the create mutation

Next, we need to update the create mutation to include the projectId in the arguments. Update convex/tasks.ts:

  export const create = mutation({
    args: {
+     projectId: v.id("projects"),
      title: v.string(),
      description: v.string(),
    },
    handler: async (ctx, args) => {
      await ctx.db.insert("tasks", {
+       projectId: args.projectId,
        title: args.title,
        description: args.description,
        status: "todo",
      });
    },
  });

Deploy the Schema

If npx convex dev is running, it will detect these changes and deploy them automatically. You should see:

✔ Schema validation complete.
✔ Convex functions ready!

Checkpoint: Commit your progress.

git add .
git commit -m "planner-02: Add projects table and projectId to tasks"
git push