Products & Data Management
Products is the sample workspace-scoped CRUD: server pagination, filters, soft delete, comments, tags, CSV import/export. Fork the pattern or delete the module once your real entity exists.
To replace Products with your own entity (Tasks, Contacts, Tickets, etc.), see Adding Your Own Model.
Data table
The Products page at /w/[slug]/products features a full-featured data table built on @tanstack/react-table.
Table features
| Feature | Description |
|---|---|
| Server-side pagination | 20 items per page (configurable). Page state synced to URL. |
| Sortable columns | Click column headers to sort by name, status, or creation date. |
| Faceted filtering | Filter by status (DRAFT, ACTIVE, ARCHIVED) with count badges. |
| Full-text search | Search products by name with debounced input. |
| Column visibility | Toggle which columns are visible via a dropdown. |
| Row selection | Select individual rows or all rows on the current page. |
| Bulk actions | Delete Selected, Archive Selected, Export CSV for selected rows. |
| URL-driven state | All filters, sorting, and pagination are encoded in URL params. |
URL parameters
The table state is fully encoded in the URL, making views shareable and bookmarkable:
/w/my-workspace/products?page=2&sort=name:asc&status=ACTIVE,DRAFT&search=widget
Where to customize
- Data table component:
src/app/(app)/w/[workspaceSlug]/products/products-data-table.tsx - Page (server component):
src/app/(app)/w/[workspaceSlug]/products/page.tsx - Page size: Change the default in the page component's fetch query
Reusable data table components
The data table is built from composable components in src/components/data-table/:
| Component | Purpose |
|---|---|
DataTable | Main table with pagination, sorting, search, and bulk actions |
DataTableColumnHeader | Sortable column header |
DataTableFacetedFilter | Multi-select filter popover with counts |
DataTableColumnToggle | Column visibility dropdown |
DataTableBulkBar | Floating action bar for selected rows |
DataTablePagination | Page navigation controls |
Use these same components when building data tables for your own entities.
CRUD operations
All product operations are implemented as server actions in src/app/(app)/w/[workspaceSlug]/products/actions.ts.
Available actions
| Action | Roles | Description |
|---|---|---|
createProduct | OWNER, ADMIN, MEMBER | Create a new product (checks plan limits) |
updateProduct | OWNER, ADMIN, MEMBER | Update name, description, or status |
softDeleteProduct | OWNER, ADMIN, MEMBER | Move to trash (sets deleted_at) |
permanentDeleteProduct | OWNER, ADMIN | Permanently remove from database |
restoreFromTrash | OWNER, ADMIN, MEMBER | Restore from trash (clears deleted_at) |
archiveProduct | OWNER, ADMIN, MEMBER | Set status to ARCHIVED |
restoreProduct | OWNER, ADMIN, MEMBER | Set status to ACTIVE |
bulkSoftDelete | OWNER, ADMIN, MEMBER | Bulk move to trash |
bulkUpdateStatus | OWNER, ADMIN, MEMBER | Bulk status change |
Server action pattern
Every action follows the same pattern:
export async function createProduct(workspaceId: string, formData: FormData) {
// 1. Authenticate
const user = await requireUser();
// 2. Authorize
await requireRole(workspaceId, user.id, ["OWNER", "ADMIN", "MEMBER"]);
// 3. Check plan limits
const limitCheck = await checkLimit(workspaceId, "records");
if (!limitCheck.allowed) return { error: "Plan limit reached." };
// 4. Validate input with Zod
const parsed = schema.safeParse({ ... });
// 5. Insert with workspace_id scoping
await supabase.from("products").insert({ workspace_id: workspaceId, ... });
// 6. Audit log
await insertAuditLog({ workspaceId, actorUserId: user.id, action: "product.created", ... });
// 7. Revalidate the page
revalidatePath(`/w/${workspaceSlug}/products`);
}
Product statuses
| Status | Description |
|---|---|
DRAFT | Default on creation. Not yet published. |
ACTIVE | Live / published. |
ARCHIVED | Hidden from active lists. |
Status badges are displayed in the data table. Click a badge to change the status inline via a dropdown.
Soft delete and trash
Products support soft delete -- "deleting" a product sets deleted_at instead of removing the row.
How it works
- Active products: Queried with
WHERE deleted_at IS NULL - Trashed products: Queried with
WHERE deleted_at IS NOT NULL - Restore: Clears
deleted_atback toNULL - Permanent delete: Actually removes the row (restricted to OWNER/ADMIN)
The Trash tab
The Products page has two tabs:
- Active -- shows products where
deleted_at IS NULL - Trash -- shows soft-deleted products with Restore and Permanent Delete actions
Where to customize
To add soft delete to your own tables:
- Add a
deleted_at TIMESTAMPTZcolumn (defaultNULL) - Add partial indexes for both active and trashed queries
- Filter by
deleted_at IS NULLin your default queries - See
supabase/003_soft_delete_and_dashboard.sqlfor the full pattern
CSV import and export
Export
Select rows in the data table and use the Export CSV bulk action to download selected products as a CSV file.
Import
Click the Import button to open the CSV import wizard:
- Upload a CSV file
- The wizard parses and validates the data
- Review the parsed rows
- Confirm to bulk-create products
Where to customize
- Import action:
src/app/(app)/w/[workspaceSlug]/products/import-action.ts - Import button:
src/app/(app)/w/[workspaceSlug]/products/product-import-button.tsx - Import wizard components:
src/components/import-wizard/
Product detail page
Each product has a detail page at /w/[slug]/products/[productId] that shows:
- Full product details (name, description, status, dates)
- Edit and archive controls
- Tags assigned to the product
- Comments thread
- File attachments
- Activity feed (recent audit events for this product)
Where to customize
- Detail page:
src/app/(app)/w/[workspaceSlug]/products/[productId]/page.tsx - Product form (create/edit dialog):
src/app/(app)/w/[workspaceSlug]/products/product-form.tsx
Tags
Tags are workspace-scoped colored labels that can be assigned to products (or any record type).
How it works
- Tags are created at the workspace level with a name and color
- Any member can assign/remove tags on products
- Only OWNER/ADMIN can create or delete tag definitions
- Tags are stored in the
tagstable, assignments inrecord_tags
Where to customize
- Tag actions:
src/lib/tags/tag-actions.ts - Tag management UI:
src/app/(app)/w/[workspaceSlug]/settings/tags-section.tsx - Database:
supabase/005_tags_comments_2fa.sql
Using tags with other entities
Tags work with any record type. To use them with your own entity:
import { addTagToRecord, removeTagFromRecord, getRecordTags } from "@/lib/tags/tag-actions";
// Add a tag
await addTagToRecord(workspaceId, taskId, tagId, "task");
// Get tags for a record
const tags = await getRecordTags(workspaceId, taskId, "task");
Comments
A threaded comment system that can be attached to any workspace record.
Features
- Add, edit, and delete comments
- Threaded replies (parent/child comments)
- @mentions with user UUIDs
- Character limit: 1-5000 characters
- Only authors can edit their own comments
- OWNER/ADMIN can delete any comment
Where to customize
- Comment actions:
src/lib/comments/comment-actions.ts - Database:
supabase/005_tags_comments_2fa.sql
Using comments with other entities
import { addComment, getComments } from "@/lib/comments/comment-actions";
// Add a comment
await addComment(workspaceId, taskId, "task", "Great work on this!", [mentionUserId]);
// Get comments
const comments = await getComments(workspaceId, taskId, "task");
File attachments
Upload and manage files attached to any workspace record using Supabase Storage.
Features
- Upload files to products (or any record type)
- File metadata stored: name, size, MIME type, storage path
- Uploaders can delete their own files
- OWNER/ADMIN can delete any file
Where to customize
- Attachment actions:
src/lib/attachments/attachment-actions.ts - Database:
supabase/006_remaining_features.sql
Using attachments with other entities
import { createAttachmentRecord, getAttachments } from "@/lib/attachments/attachment-actions";
// Create an attachment record after uploading to Supabase Storage
await createAttachmentRecord(workspaceId, taskId, "task", fileName, fileSize, mimeType, storagePath);
// Get attachments
const files = await getAttachments(workspaceId, taskId, "task");
Plan limit enforcement
Product creation checks subscription plan limits before allowing the operation:
const limitCheck = await checkLimit(workspaceId, "records");
if (!limitCheck.allowed) return { error: "Plan limit reached." };
Plans define limits for:
- Seats -- maximum number of workspace members
- Records -- maximum number of products (Enterprise = unlimited)
See Billing & Subscriptions for details on plan configuration.