Adding Your Own Data Model
Products exists to show how RLS, server actions, and tables line up. Swap the name, columns, and routes when you model tickets, contacts, or whatever you actually sell.
What "Products" demonstrates
The Products feature is a complete reference implementation showing how to wire up:
| Pattern | Location |
|---|---|
| Database table + migrations | supabase/001_schema.sql |
| Row-Level Security | supabase/002_rls.sql |
| Soft delete + trash | supabase/003_soft_delete_and_dashboard.sql |
| Server actions (CRUD + bulk) | src/app/(app)/w/[workspaceSlug]/products/actions.ts |
| Advanced data table | src/app/(app)/w/[workspaceSlug]/products/products-data-table.tsx |
| Server-side pagination + filtering | src/app/(app)/w/[workspaceSlug]/products/page.tsx |
| Detail page + activity feed | src/app/(app)/w/[workspaceSlug]/products/[productId]/page.tsx |
| Tags / labels | src/lib/tags/tag-actions.ts |
| Comments & @mentions | src/lib/comments/comment-actions.ts |
| File attachments | src/lib/attachments/attachment-actions.ts |
| Custom fields | src/lib/custom-fields/custom-field-actions.ts |
| CSV import + export | src/components/import-wizard/ |
| Audit logging | Every action calls insertAuditLog() |
| RBAC permission checks | Every action calls requireRole() |
| Plan limit enforcement | createProduct calls checkLimit() |
Example: what buyers build
| If you're building a... | Replace "Products" with... |
|---|---|
| Project management tool | Tasks, Projects |
| CRM | Contacts, Deals, Companies |
| Help desk / Support | Tickets |
| Content platform | Posts, Articles |
| Inventory system | Items, SKUs |
| HR tool | Employees, Job Listings |
| Learning platform | Courses, Lessons |
Step-by-step: adding a new entity
1. Create the database table
Add a new migration file (e.g., supabase/007_your_entity.sql):
CREATE TABLE IF NOT EXISTS public.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (char_length(title) >= 1 AND char_length(title) <= 200),
description TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'done', 'closed')),
assignee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
priority TEXT DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
due_date TIMESTAMPTZ,
custom_fields JSONB DEFAULT '{}',
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX tasks_workspace_idx ON public.tasks(workspace_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX tasks_status_idx ON public.tasks(workspace_id, status) WHERE deleted_at IS NULL;
-- Auto-update timestamp
CREATE TRIGGER tasks_updated_at
BEFORE UPDATE ON public.tasks
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tasks_select" ON public.tasks FOR SELECT USING (is_workspace_member(workspace_id));
CREATE POLICY "tasks_insert" ON public.tasks FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
CREATE POLICY "tasks_update" ON public.tasks FOR UPDATE USING (is_workspace_member(workspace_id));
CREATE POLICY "tasks_delete" ON public.tasks FOR DELETE USING (workspace_role(workspace_id) IN ('OWNER', 'ADMIN'));
2. Create server actions
Copy src/app/(app)/w/[workspaceSlug]/products/actions.ts and adapt:
// src/app/(app)/w/[workspaceSlug]/tasks/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireUser } from "@/lib/auth/require-user";
import { requireRole } from "@/lib/rbac/require-membership";
import { createAdminClient } from "@/lib/supabase/admin";
import { insertAuditLog } from "@/lib/audit/insert-log";
import { checkLimit } from "@/lib/billing/check-limit";
const taskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
status: z.enum(["open", "in_progress", "done", "closed"]).optional(),
priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
});
export async function createTask(workspaceId: string, formData: FormData) {
const user = await requireUser();
await requireRole(workspaceId, user.id, ["OWNER", "ADMIN", "MEMBER"]);
// Check plan limits
const limitCheck = await checkLimit(workspaceId, "records");
if (!limitCheck.allowed) return { error: "Plan limit reached." };
// Validate, insert, audit log, revalidate...
}
3. Create the page
Copy the Products page structure:
src/app/(app)/w/[workspaceSlug]/tasks/
├── page.tsx # Server component (fetch + pagination)
├── tasks-data-table.tsx # Client component (columns + actions)
├── task-form.tsx # Create/edit dialog
├── actions.ts # Server actions
└── [taskId]/
└── page.tsx # Detail page
4. Add to sidebar navigation
In src/components/dashboard/dashboard-sidebar.tsx, add your entity to buildWorkspaceNav:
{ id: "tasks", label: "Tasks", href: `/w/${workspaceSlug}/tasks`, icon: CheckSquare },
5. Add to routes config
In src/config/routes.ts:
TASKS: (slug: string) => `/w/${slug}/tasks` as const,
TASK: (slug: string, id: string) => `/w/${slug}/tasks/${id}` as const,
6. Reuse existing systems
All these work automatically with your new entity:
- Tags: Use
addTagToRecord(workspaceId, taskId, tagId, "task") - Comments: Use
addComment(workspaceId, taskId, "task", body, mentions) - Attachments: Use
createAttachmentRecord(workspaceId, taskId, "task", ...) - Custom fields: Use the same
custom_fields JSONBpattern - Activity feed: Just call
insertAuditLog()withresourceType: "task" - Bookmarks: Use
toggleBookmark(workspaceId, "task", taskId, taskTitle) - Saved views: Works if you use the same DataTable + URL params pattern
7. Update action labels
In src/components/activity-feed/action-labels.ts, add:
"task.created": "created a task",
"task.updated": "updated a task",
"task.deleted": "moved a task to trash",
Tips
- Keep workspace scoping: Every table needs
workspace_idand every query should filter by it - Always audit: Call
insertAuditLog()after every mutation - Always authorize: Call
requireRole()before every mutation - Use soft delete: Add
deleted_atinstead of hard deleting - Use the DataTable: The reusable
<DataTable>component handles pagination, sorting, filtering, and bulk actions for any entity