Quickdash currently has 4 hardcoded content types (FAQ, Stats, Testimonials, Site Content) with specific tables, API endpoints, and admin pages — built specifically for Gemsutopia. This doesn't scale. If a portfolio site joins, they need "Projects" and "Skills." If a restaurant joins, they need "Menu Items" and "Hours." We can't create new tables and endpoints for every user.
Goal: Replace the hardcoded content types with a generic, schema-driven collections system. One set of tables, one API endpoint, one admin UI — serves any content type for any user. Users define their own content types through the admin dashboard, and the system auto-generates the API and management interface.
What stays:
- Blog Posts, Site Pages, Media Library (specialized features with complex behavior)
- Site Content key-value store (already generic)
- All e-commerce features (products, orders, etc.)
- Storefront/Admin API key system — users still add
X-Storefront-Keyto their env.local/Vercel. The SDK andwithStorefrontAuthwrapper stay exactly the same. We're just replacing the hardcoded endpoints with generic ones.
What gets replaced: FAQ table + endpoint, Stats table + endpoint, Testimonials table + endpoint → all become generic collections.
Approach: Build and test everything against local Docker PostgreSQL first. Only push to Neon after confirming everything works locally.
content_collections table:
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | defaultRandom |
| workspaceId | uuid FK→workspaces | cascade delete |
| name | text NOT NULL | Display name ("FAQ", "Team Members") |
| slug | text NOT NULL | URL-safe ("faq", "team-members") |
| description | text | Optional description shown in admin |
| icon | text | Icon name for sidebar (e.g., "star", "users") |
| schema | jsonb NOT NULL | Field definitions (see below) |
| allowPublicSubmit | boolean | If true, storefront can POST new entries |
| publicSubmitStatus | text DEFAULT 'inactive' | Default isActive for public submissions |
| isActive | boolean DEFAULT true | Collection visibility |
| sortOrder | integer DEFAULT 0 | Sidebar ordering |
| createdAt | timestamp | |
| updatedAt | timestamp |
- Unique constraint:
(workspaceId, slug) - Index:
content_collections_workspace_idxonworkspaceId
content_entries table:
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | defaultRandom |
| collectionId | uuid FK→content_collections | cascade delete |
| workspaceId | uuid FK→workspaces | cascade delete (denormalized for query perf) |
| data | jsonb NOT NULL DEFAULT '{}' | The actual field values |
| isActive | boolean DEFAULT true | Visibility toggle |
| sortOrder | integer DEFAULT 0 | Manual ordering |
| createdAt | timestamp | |
| updatedAt | timestamp | |
| updatedBy | text FK→user | Who last edited |
- Indexes:
content_entries_collection_idxoncollectionId,content_entries_workspace_idxonworkspaceId
type FieldType = "text" | "textarea" | "number" | "boolean" | "select" | "image" | "url" | "email" | "date" | "rating" | "color"
type CollectionField = {
key: string // "question", "name", "rating"
label: string // "Question", "Full Name"
type: FieldType
required?: boolean
placeholder?: string
defaultValue?: unknown
options?: { label: string; value: string }[] // for "select" type
}
type CollectionSchema = {
fields: CollectionField[]
settings: {
titleField: string // field key used as display title in table
descriptionField?: string // optional subtitle in table
imageField?: string // optional thumbnail column
defaultSort?: string // "sortOrder" | field key
defaultSortDir?: "asc" | "desc"
}
}Add export * from "./content-collections" to packages/db/src/schema/index.ts.
npx drizzle-kit push (local Docker only — Neon push deferred until verified)
GET /api/storefront/collections/:slug
- Auth:
withStorefrontAuth(same API key system as before) - Lookup collection by
slug+storefront.workspaceId - Return entries where
isActive = true, ordered bysortOrderASC - Support query param filters on data fields:
?filter[status]=approved&filter[isFeatured]=true- Translates to:
WHERE data->>'status' = 'approved' AND data->>'isFeatured' = 'true'
- Translates to:
- Support
?sort=createdAt&order=desc - Response:
{ collection: { name, slug, description }, entries: [{ id, data, sortOrder, createdAt }] }
POST /api/storefront/collections/:slug/entries
- Auth:
withStorefrontAuth - Check
collection.allowPublicSubmit === true, else 403 - Validate submitted data against collection schema (required fields)
- Create entry with
isActive = false(or whateverpublicSubmitStatussays) — needs admin moderation - Response:
{ entry: { id, data, createdAt } }
GET /api/storefront/collections
- Auth:
withStorefrontAuth - List all active collections for workspace (name, slug, description, field count)
- Response:
{ collections: [{ name, slug, description }] }
apps/admin/app/api/storefront/faq/route.ts→ DELETEapps/admin/app/api/storefront/stats/route.ts→ DELETEapps/admin/app/api/storefront/testimonials/route.ts→ DELETE
apps/admin/app/api/storefront/site-content/route.ts→ STAYS (key-value is a different pattern)
Collection CRUD:
getCollections()— list all collections for workspacegetCollection(slug: string)— get single collection with its schemacreateCollection(data: { name, slug, description, icon, schema, allowPublicSubmit })— create new collectionupdateCollection(id, data)— update collection metadata/schemadeleteCollection(id)— delete collection + cascade entries
Entry CRUD:
getEntries(collectionId, params?: { page, pageSize, search, filters })— paginated entriescreateEntry(collectionId, data: Record<string, unknown>)— create entryupdateEntry(entryId, data)— update entry data/isActive/sortOrderdeleteEntry(entryId)— delete single entrybulkDeleteEntries(ids: string[])— bulk deletebulkToggleEntries(ids: string[], isActive: boolean)— bulk activate/deactivate
All actions use requireContentPermission() from existing content/actions.ts.
File: apps/admin/app/(dashboard)/content/collections/page.tsx
Server component that lists all collections in a DataTable. Columns: Name, Slug, Entry Count, Active toggle, Actions (Edit/Delete). "New Collection" button opens dialog.
File: apps/admin/app/(dashboard)/content/collections/[slug]/page.tsx
Server component that fetches the collection schema + entries. Renders a dynamic DataTable where columns are generated from the schema's fields.
File: apps/admin/app/(dashboard)/content/collections/[slug]/entries-table.tsx
Client component. Key features:
- Dynamic columns — generated from
collection.schema.fields. Each field becomes a column. ThetitleFieldrenders as bold. Boolean fields render as switches. Select fields render as badges. - Dynamic create/edit form — dialog with fields rendered by type (see FieldRenderer below)
- Bulk actions — delete, activate/deactivate
- Search — searches across the
titleFieldvalue - Collection settings button (opens schema editor dialog)
File: apps/admin/app/(dashboard)/content/collections/[slug]/field-renderer.tsx
Shared component that renders a form field based on its type definition:
text→<Input />textarea→<Textarea rows={3} />number→<Input type="number" />boolean→<Switch />select→<Select>with optionsimage→<Input type="url" />(later: media picker)url→<Input type="url" />email→<Input type="email" />date→<Input type="date" />rating→<Input type="number" min={1} max={5} />color→<Input type="color" />
File: apps/admin/app/(dashboard)/content/collections/[slug]/schema-editor.tsx
Dialog/drawer component for editing collection schema:
- List of fields with key, label, type, required checkbox
- Add field button
- Remove field button
- Reorder fields (up/down buttons for v1, drag later)
- Collection settings: name, slug, description, icon, allowPublicSubmit
File: apps/admin/app/(dashboard)/content/collections/new/page.tsx
Two options:
- Start from template — pre-defined field schemas for common types (FAQ, Testimonials, Team Members, Gallery, Partners, etc.)
- Start blank — empty collection, add fields manually
Template definitions are a static array in a collection-templates.ts file.
Add query to fetch workspace collections and pass to sidebar.
Replace hardcoded FAQ/Testimonials/Stats entries with dynamic collections:
Content
├── Blog Posts
├── Pages
├── {collection.name} ← dynamic, one per collection
├── ...more collections...
├── All Collections ← manage collections page
├── Site Content
├── Media Library
SQL migration script to create FAQ, Stats, and Testimonials collections from existing table data.
INSERT INTO content_entries from old faq, stats, testimonials tables.
Count rows: old tables vs new entries per collection.
- Add generic
collectionsnamespace - Keep
faq,stats,testimonials,siteContentas convenience wrappers callingcollectionsunder the hood - Existing
store.faq.list()calls still work without changing page.tsx
- Replace direct DB write with
store.collections.submit('testimonials', {...}) - Gemsutopia can remove
DATABASE_URLandWORKSPACE_IDenv vars
After migration is verified working:
- Delete old storefront endpoints: faq, stats, testimonials route files
- Delete old admin pages: content/faq, content/stats, content/testimonials dirs
- Remove old server actions from
content/actions.ts: FAQ, Stats, Testimonials functions - Old tables: Leave in DB as backup. Remove schema exports later.
| File | Purpose |
|---|---|
packages/db/src/schema/content-collections.ts |
Drizzle schema for both tables |
apps/admin/app/api/storefront/collections/route.ts |
List collections endpoint |
apps/admin/app/api/storefront/collections/[slug]/route.ts |
Get collection entries endpoint |
apps/admin/app/api/storefront/collections/[slug]/entries/route.ts |
Public submit endpoint |
apps/admin/app/(dashboard)/content/collections/actions.ts |
Server actions |
apps/admin/app/(dashboard)/content/collections/page.tsx |
Collections list page |
apps/admin/app/(dashboard)/content/collections/new/page.tsx |
New collection page |
apps/admin/app/(dashboard)/content/collections/[slug]/page.tsx |
Entries page |
apps/admin/app/(dashboard)/content/collections/[slug]/entries-table.tsx |
Dynamic entries DataTable |
apps/admin/app/(dashboard)/content/collections/[slug]/field-renderer.tsx |
Dynamic form field component |
apps/admin/app/(dashboard)/content/collections/[slug]/schema-editor.tsx |
Collection schema editor |
apps/admin/app/(dashboard)/content/collections/collection-templates.ts |
Starter templates |
| File | Change |
|---|---|
packages/db/src/schema/index.ts |
Add content-collections export |
apps/admin/app/(dashboard)/layout.tsx |
Fetch collections, pass to sidebar |
apps/admin/components/app-sidebar.tsx |
Dynamic collection nav items |
apps/admin/app/(dashboard)/content/actions.ts |
Remove FAQ/Stats/Testimonials actions (after verified) |
| File | Change |
|---|---|
apps/web/src/lib/storefront-client.ts |
Add generic collections namespace, rewire convenience methods |
apps/web/src/app/api/reviews/route.ts |
Replace direct DB write with collections.submit() |
| File | Reason |
|---|---|
apps/admin/app/api/storefront/faq/route.ts |
Replaced by generic endpoint |
apps/admin/app/api/storefront/stats/route.ts |
Replaced by generic endpoint |
apps/admin/app/api/storefront/testimonials/route.ts |
Replaced by generic endpoint |
apps/admin/app/(dashboard)/content/faq/* |
Replaced by generic collections UI |
apps/admin/app/(dashboard)/content/stats/* |
Replaced by generic collections UI |
apps/admin/app/(dashboard)/content/testimonials/* |
Replaced by generic collections UI |
- Push schema to local DB:
npx drizzle-kit push - Build Quickdash:
npx turbo run build --filter=@quickdash/admin - Run migration script to seed collections + migrate data (local only)
- Test admin UI locally:
- Visit
/content/collections— see FAQ, Stats, Testimonials - Click into one — see entries in DataTable
- Create/edit/delete entries
- Create a new collection from template
- Create a blank collection, add fields
- Verify sidebar shows collections dynamically
- Visit
- Test storefront API locally:
GET /api/storefront/collections/faqwith API keyGET /api/storefront/collections/testimonials?filter[status]=approvedPOST /api/storefront/collections/testimonials/entries(public submit)
- CONFIRM WITH USER before pushing to Neon
- Push schema to Neon (only after user approval)
- Run migration on Neon
- Update Gemsutopia to use new generic endpoints
- Build Gemsutopia:
pnpm build:web - Test Gemsutopia homepage: FAQ, stats, testimonials load via new generic endpoint
See git history for the full channels/servers plan. Deferred to focus on content collections system first.