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
- Removing the Workspace Demo — how to rip this demo out when building your real app.
Capabilities
While Roles dictate what a user is allowed to do within an organization, Capabilities dictate what the organization itself is allowed to do based on its billing plan.
Switching to Personal Mode
If your product is a B2C application or a prosumer tool where users do not collaborate with others, you don't need Team Workspaces.