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:

PatternLocation
Database table + migrationssupabase/001_schema.sql
Row-Level Securitysupabase/002_rls.sql
Soft delete + trashsupabase/003_soft_delete_and_dashboard.sql
Server actions (CRUD + bulk)src/app/(app)/w/[workspaceSlug]/products/actions.ts
Advanced data tablesrc/app/(app)/w/[workspaceSlug]/products/products-data-table.tsx
Server-side pagination + filteringsrc/app/(app)/w/[workspaceSlug]/products/page.tsx
Detail page + activity feedsrc/app/(app)/w/[workspaceSlug]/products/[productId]/page.tsx
Tags / labelssrc/lib/tags/tag-actions.ts
Comments & @mentionssrc/lib/comments/comment-actions.ts
File attachmentssrc/lib/attachments/attachment-actions.ts
Custom fieldssrc/lib/custom-fields/custom-field-actions.ts
CSV import + exportsrc/components/import-wizard/
Audit loggingEvery action calls insertAuditLog()
RBAC permission checksEvery action calls requireRole()
Plan limit enforcementcreateProduct calls checkLimit()

Example: what buyers build

If you're building a...Replace "Products" with...
Project management toolTasks, Projects
CRMContacts, Deals, Companies
Help desk / SupportTickets
Content platformPosts, Articles
Inventory systemItems, SKUs
HR toolEmployees, Job Listings
Learning platformCourses, 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 JSONB pattern
  • Activity feed: Just call insertAuditLog() with resourceType: "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_id and 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_at instead of hard deleting
  • Use the DataTable: The reusable <DataTable> component handles pagination, sorting, filtering, and bulk actions for any entity