Leave Localhost logoLeave LocalhostDocs
Multi-tenancy

Workspace Demo

The starter includes a "Workspace Records" demo feature to illustrate how tenant-scoped data works in practice. It consists of a simple CRUD application for managing "Records" (like tasks or documents).

The starter includes a "Workspace Records" demo feature to illustrate how tenant-scoped data works in practice. It consists of a simple CRUD application for managing "Records" (like tasks or documents).

Data Model

The workspace_records table in packages/backend/convex/schema.ts:

workspace_records: defineTable({
  organizationId: v.string(), // The tenant boundary
  title: v.string(),
  status: v.union(v.literal("open"), v.literal("in_progress"), v.literal("done")),
  createdByUserId: v.id("users"),
  createdAt: v.number(),
  updatedAt: v.number(),
}).index("by_organizationId", ["organizationId"]),

The organizationId field is what ties the data to a specific workspace. The by_organizationId index makes querying fast.

Backend Implementation

The backend logic lives in packages/backend/convex/workspaceRecords.ts.

Creating a Record

export const createRecord = mutation({
  args: { status: workspaceRecordStatusValidator, title: v.string() },
  handler: async (ctx, args) => {
    // 1. Verify access and get the actor's organization
    const actor = await requireAppPermission(ctx, {
      permission: "workspace.records.create",
    });

    // 2. Insert with the organization ID
    const recordId = await ctx.db.insert("workspace_records", {
      organizationId: actor.organizationId,
      title: args.title,
      status: args.status,
      createdByUserId: actor.appUserId,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
    return { id: recordId };
  },
});

Listing Records

export const listRecords = query({
  args: {},
  handler: async (ctx) => {
    const actor = await requireAppPermission(ctx, {
      permission: "workspace.records.read",
    });

    // Query scoped strictly to the actor's organization
    return await ctx.db
      .query("workspace_records")
      .withIndex("by_organizationId", (q) =>
        q.eq("organizationId", actor.organizationId),
      )
      .order("desc")
      .take(50);
  },
});

Updating and Deleting

When updating or deleting, you must verify that the record actually belongs to the active organization:

async function getOwnedRecord(ctx, recordId, organizationId) {
  const record = await ctx.db.get(recordId);

  // If missing or belongs to another org, throw notFound.
  // Using notFound instead of forbidden prevents ID enumeration.
  if (!record || record.organizationId !== organizationId) {
    throw notFound("Record not found.");
  }
  return record;
}

UI Implementation

The frontend components live in apps/app/src/app/[locale]/(dashboard)/. They use the useQuery and useMutation hooks from convex/react to interact with the backend.

The UI leverages Shadcn data tables to list records, and forms to create and edit them. The components automatically re-render when the workspace switcher is used, showing the records for the newly active workspace.

Next Reads

On this page