Skip to content

Products System

The products system provides comprehensive product catalog management with support for multiple categories, product variants, inventory tracking, and flexible pricing. Products can target multiple demographics and include customizable size/color options.

The products system supports:

  • Multi-Category Classification: Products can belong to multiple categories (e.g., apparel + accessories)
  • Multi-Gender Targeting: Products can target multiple demographics (e.g., male + female + kids)
  • Product Variants: Size and color options stored as arrays
  • Inventory Management: Stock status tracking with quantity management
  • Flexible Pricing: Decimal pricing with 2-digit precision
  • Image Support: Optional image URL for product display
  • Configuration-Driven: All options defined in config/products.config.ts
  • No Ownership Model: Global catalog accessible to all (read-only), managed by admins

📦 Product Catalog

Manage product information, pricing, inventory, and variants. Multi-select categories and genders provide maximum flexibility.

🎨 Product Variants

Support size and color variations for each product. Checkbox-based selection makes variant management intuitive.

💰 Pricing & Inventory

Track pricing with 2-decimal precision, inventory quantity, and stock availability. Optimize stock management with real-time updates.

🔒 Secure by Default

Role-based access ensures only admins can modify products. All authenticated users can browse the catalog.

import { getDatabase } from '#libs/database'
import type { ProductData } from '#libs/database'
const db = getDatabase()
// Minimal product (only required fields)
const minimalProduct: ProductData = {
product_name: 'Basic T-Shirt',
price: 19.99,
genders: ['unisex'], // At least 1 gender required
}
const productId = await db.insertProduct(minimalProduct)
console.log(`Product created: ${productId}`)
// Product with variants
const variantProduct: ProductData = {
product_name: 'Classic Hoodie',
product_description: 'Comfortable cotton blend hoodie',
image_url: 'https://example.com/hoodie.jpg',
categories: ['apparel'],
sizes: ['s', 'm', 'l', 'xl', 'xxl'],
colors: ['light', 'dark'],
in_stock: true,
quantity: 50,
price: 49.99,
genders: ['male', 'female', 'unisex'],
}
const variantProductId = await db.insertProduct(variantProduct)
// Full product data with all options
const fullProduct: ProductData = {
product_name: 'Premium Sneakers',
product_description: 'High-quality athletic footwear with superior comfort',
image_url: 'https://example.com/sneakers.jpg',
categories: ['footwear', 'accessories'], // Multiple categories
sizes: ['m', 'l', 'xl'],
colors: ['light', 'dark'],
in_stock: true,
quantity: 100,
price: 89.99,
genders: ['male', 'female', 'kids'], // Multiple genders
}
const fullProductId = await db.insertProduct(fullProduct)
// Get all products (newest first)
const allProducts = await db.getProducts({
limit: 20,
order_by: 'created_at',
order_direction: 'desc',
})
// Filter by category and gender
const mensApparel = await db.getProducts({
categories: ['apparel'],
genders: ['male'],
limit: 10,
})
// Filter by price range
const affordableProducts = await db.getProducts({
min_price: 10.0,
max_price: 50.0,
in_stock: true,
limit: 20,
})
FieldTypeDescriptionExampleValidation
product_namestringProduct name”Classic Hoodie”Min 1 char, max 255 chars
pricenumberProduct price49.99>= 0, max 2 decimal places
gendersProductGender[]Target demographics (array)[“male”, “female”]Min 1 element, valid enum values (see below)

Gender Values:

  • male - Men’s products
  • female - Women’s products
  • unisex - Gender-neutral products
  • kids - Children’s products
FieldTypeDescriptionExampleValidation
product_descriptionstringProduct description”Comfortable hoodie…”Max 1000 chars
image_urlstringProduct image URL”https://example.com/img.jpg”Valid URL format
categoriesProductCategory[]Product categories (array)[“apparel”, “accessories”]Valid enum values (see below)
sizesstring[]Available sizes (array)[“s”, “m”, “l”, “xl”]Valid size IDs from config
colorsstring[]Available colors (array)[“light”, “dark”]Valid color IDs from config
in_stockbooleanInventory availabilitytrueBoolean, default true
quantitynumberAvailable quantity50Integer, >= 0, default 0

Category Values:

  • apparel - Clothing items
  • accessories - Supplementary items
  • footwear - Shoes and footwear
FieldTypeDescription
idstringUnique UUID
created_atstringISO 8601 timestamp
updated_atstringISO 8601 timestamp

Create a product with multiple sizes and colors:

import { getDatabase } from '#libs/database'
async function createProductWithVariants() {
const db = getDatabase()
const productData: ProductData = {
product_name: 'Classic Hoodie',
product_description: 'Comfortable cotton blend hoodie perfect for layering',
image_url: 'https://example.com/images/classic-hoodie.jpg',
categories: ['apparel'],
sizes: ['s', 'm', 'l', 'xl', 'xxl'], // All standard sizes
colors: ['light', 'dark'], // Available colors
in_stock: true,
quantity: 100,
price: 49.99,
genders: ['male', 'female', 'unisex'], // Target all demographics
}
try {
const productId = await db.insertProduct(productData)
console.log(`Product created with variants: ${productId}`)
return productId
} catch (error) {
console.error('Failed to create product:', error)
throw error
}
}

Display products filtered by specific criteria:

const apparelProducts = await db.getProducts({
categories: ['apparel'],
limit: 20,
})

Find products within a specific price range:

async function getAffordableProducts() {
const db = getDatabase()
return await db.getProducts({
min_price: 10.0,
max_price: 50.0,
in_stock: true, // Only show available products
limit: 50,
order_by: 'price',
order_direction: 'asc', // Cheapest first
})
}
// Usage
const budgetFriendly = await getAffordableProducts()
console.log(`Found ${budgetFriendly.length} affordable products`)

Update stock status and quantity after a sale:

async function updateInventoryAfterSale(productId: string, soldQuantity: number) {
const db = getDatabase()
// Get current product
const product = await db.getProductById(productId)
if (!product) {
throw new Error('Product not found')
}
// Calculate new inventory
const newQuantity = product.quantity - soldQuantity
const inStock = newQuantity > 0
// Update product
const success = await db.updateProduct(productId, {
quantity: newQuantity,
in_stock: inStock,
})
if (!success) {
throw new Error('Failed to update inventory')
}
console.log(`Inventory updated: ${newQuantity} remaining`)
}

Implement paginated product catalog:

async function getProductsPage(page: number, pageSize: number = 10) {
const db = getDatabase()
const offset = (page - 1) * pageSize
const [products, totalCount] = await Promise.all([
db.getProducts({
limit: pageSize,
offset,
order_by: 'created_at',
order_direction: 'desc',
}),
db.getProductsCount(),
])
const totalPages = Math.ceil(totalCount / pageSize)
return {
products,
pagination: {
currentPage: page,
pageSize,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
},
}
}
// Usage
const result = await getProductsPage(1, 10)
console.log(`Page ${result.pagination.currentPage} of ${result.pagination.totalPages}`)
result.products.forEach(product => {
console.log(`${product.product_name} - $${product.price}`)
})

Search products by name or description:

async function searchProducts(query: string) {
const db = getDatabase()
return await db.getProducts({
search: query, // Searches product_name and product_description
limit: 20,
})
}
// Usage
const hoodies = await searchProducts('hoodie')
console.log(`Found ${hoodies.length} products matching "hoodie"`)

Prepare products for catalog display with images and pricing:

import { formatProductPrice } from '#utils/products'
async function getProductCatalog() {
const db = getDatabase()
const products = await db.getProducts({
fields: ['id', 'product_name', 'image_url', 'price', 'in_stock'],
in_stock: true, // Only show available products
limit: 50,
})
return products.map(product => ({
id: product.id,
name: product.product_name,
imageUrl: product.image_url || '/images/placeholder.jpg',
priceLabel: formatProductPrice(product.price), // "$49.99"
available: product.in_stock,
}))
}

Filter products by various criteria:

const apparelProducts = await db.getProducts({
categories: ['apparel', 'accessories'], // Multiple categories
})

Control result order:

// Newest products first (default)
const newest = await db.getProducts({
order_by: 'created_at',
order_direction: 'desc',
})
// Alphabetical by name
const alphabetical = await db.getProducts({
order_by: 'product_name',
order_direction: 'asc',
})
// By price (low to high)
const byPrice = await db.getProducts({
order_by: 'price',
order_direction: 'asc',
})
// By inventory quantity
const byQuantity = await db.getProducts({
order_by: 'quantity',
order_direction: 'desc',
})

Select only needed fields for better performance:

// Minimal data for list views
const productList = await db.getProducts({
fields: ['id', 'product_name', 'price', 'image_url'],
limit: 100,
})
// With categories and genders
const withMetadata = await db.getProducts({
fields: ['id', 'product_name', 'price', 'categories', 'genders', 'in_stock'],
limit: 100,
})

All product options are defined in config/products.config.ts:

Available Categories:

  • apparel - Clothing items (shirts, pants, jackets)
  • accessories - Supplementary items (bags, hats, jewelry)
  • footwear - Shoes and footwear items

Available Genders:

  • male - Men’s products
  • female - Women’s products
  • unisex - Gender-neutral products
  • kids - Children’s products

Size Definitions:

// From config/products.config.ts
export const PRODUCT_SIZES = [
{ id: 's', name: 'S', displayOrder: 1, active: true },
{ id: 'm', name: 'M', displayOrder: 2, active: true },
{ id: 'l', name: 'L', displayOrder: 3, active: true },
{ id: 'xl', name: 'XL', displayOrder: 4, active: true },
{ id: 'xxl', name: 'XXL', displayOrder: 5, active: true },
]

Color Definitions:

// From config/products.config.ts
export const PRODUCT_COLORS = [
{ id: 'light', name: 'Light', displayOrder: 1, active: true },
{ id: 'dark', name: 'Dark', displayOrder: 2, active: true },
]
import { getActiveSizes, getActiveColors } from '#config/products.config'
// Get all active sizes sorted by display order
const sizes = getActiveSizes()
// Returns: [{ id: 's', name: 'S', ... }, { id: 'm', name: 'M', ... }, ...]
// Get all active colors sorted by display order
const colors = getActiveColors()
// Returns: [{ id: 'light', name: 'Light', ... }, { id: 'dark', name: 'Dark', ... }]

Complete validation reference:

FieldRequiredMin/MaxFormatSpecial Rules
product_nameYes1-255 charsTextTrimmed
product_descriptionNo0-1000 charsTextTrimmed
image_urlNoN/AURLValid URL format if provided
sizesNoN/AArrayValid size IDs from PRODUCT_SIZES
colorsNoN/AArrayValid color IDs from PRODUCT_COLORS
in_stockNoN/ABooleanDefault: true
quantityYes>= 0IntegerNon-negative, default: 0
priceYes>= 0DecimalMax 2 decimal places
categoriesNoN/AArrayValid values from PRODUCT_CATEGORIES
gendersYesMin 1 elementArrayValid values from PRODUCT_GENDERS

Form component for creating/editing products using React Hook Form + Zod validation:

File: src/components/react/ProductFormRHF.tsx

Props:

type Props = {
csrfToken: string // CSRF token for form submission
product?: Product | null // For edit mode (optional)
apiEndpoint?: string // Custom API endpoint (optional)
}

Usage in Astro:

---
import ProductFormRHF from '#components/react/ProductFormRHF.tsx'
import { generateCSRFToken } from '#utils/csrf'
const csrfToken = await generateCSRFToken(Astro.locals.userId)
---
<ProductFormRHF client:load csrfToken={csrfToken} />

Features:

  • One Card per section (Basic Information, Product Variants, Categorization, Inventory & Pricing)
  • Checkbox chips for multi-select categories, sizes, colors, and genders
  • Real-time validation with error messages
  • Error summary with anchor links to fields
  • Submission error display
  • CSRF token integration
  • @fpkit/acss components for accessibility
  • Unsaved-changes guard (useUnsavedChangesGuard): while the form is dirty, leaving the page raises the browser’s confirmation first. This matters most beside the recent-products rail, whose edit links are ordinary navigations. A successful save releases the guard before it redirects, so saving never prompts.

Implementation Highlights:

The form demonstrates checkbox-based multi-select pattern for array fields:

// Categories checkboxes
{
getActiveCategories().map(category => (
<Controller
key={category}
name="categories"
control={control}
render={({ field }) => (
<Checkbox
id={`category-${category}`}
label={formatCategory(category)}
checked={field.value?.includes(category) ?? false}
onChange={checked => {
const newValue = checked
? [...(field.value ?? []), category]
: (field.value ?? []).filter(c => c !== category)
field.onChange(newValue)
}}
/>
)}
/>
))
}

This pattern ensures proper state management for multi-select checkboxes.

Presentation component for displaying product lists:

File: src/components/react/ProductsListView.tsx

Props:

type ProductsListViewProps = {
products: Product[]
isLoading?: boolean
showActions?: boolean
emptyMessage?: string
currentPage?: number
totalPages?: number
onPageChange?: (page: number) => void
}

Usage:

---
import ProductsListView from '#components/react/ProductsListView.tsx'
import { getDatabase } from '#libs/database'
const db = getDatabase()
const products = await db.getProducts({
limit: 10,
order_by: 'created_at',
order_direction: 'desc',
})
---
<ProductsListView products={products} showActions={true} />

Features:

  • Card-based grid layout using @fpkit/acss
  • Displays name, price, categories, genders, stock status, quantity
  • Product image with fallback
  • Loading state with accessible alert
  • Empty state with custom message
  • Pagination controls (Previous/Next buttons)
  • Edit action buttons for each product

Server-rendered wrapper component that handles data fetching, authorization, and error handling for product forms:

File: src/components/astro/forms/ProductFormWrapper.astro

Props:

type Props = {
// Mode detection
productId?: string // Product ID for edit mode (auto-detects edit mode if provided)
mode?: 'create' | 'edit' // Explicitly set mode (auto-detected from productId if not provided)
// Data overrides
product?: Product | null // Pre-fetched product data (will fetch in edit mode if not provided)
csrfToken?: string // CSRF token (uses Astro.locals.csrfToken if not provided)
// Configuration
apiEndpoint?: string // API endpoint override (auto-generated if not provided)
showErrors?: boolean // Show error alerts (default: true)
hydration?: 'load' | 'visible' | 'idle' // Hydration strategy (default: 'load')
className?: string // Additional CSS class name
// Loading & Error
showLoadingSkeleton?: boolean // Show loading skeleton during fetch (default: true)
loadingMessage?: string // Loading message (default: "Loading product form...")
errorMessages?: {
notFound?: string
unauthorized?: string
fetchFailed?: string
}
// Authorization
requiredRoles?: string[] // Roles that can edit without ownership (default: ['super_admin'])
skipOwnershipCheck?: boolean // Skip ownership check (default: false)
}

Usage:

---
import ProductFormWrapper from '#components/astro/forms/ProductFormWrapper.astro'
---
<ProductFormWrapper />

Features:

  • Auto-detects create vs edit mode from productId prop
  • Fetches product data server-side before rendering
  • Loading skeleton during data fetch
  • Environment-aware error messages (detailed in dev, safe in production)
  • CSRF token from Astro.locals.csrfToken or explicit prop
  • Configurable hydration strategy (load, visible, idle)
<!-- Allow team_admin to edit products -->
<ProductFormWrapper
productId={productId}
requiredRoles={['super_admin', 'admin', 'team_admin']}
skipOwnershipCheck={true}
/>

API Endpoints Used:

  • Create: POST /api/products/create
  • Edit: PUT /api/products/edit/[id]

See also: Form Wrapper Components Guide for advanced usage patterns.

All product operations require:

  • Valid Clerk JWT token
  • User record in database (synced from Clerk)

Public Read:

  • All authenticated users can view all products
  • No role restrictions for browsing catalog
  • Enables organization-wide product search

Admin Write:

  • Only team_admin, admin, and super_admin roles can create/update/delete products
  • Role verification at API endpoint level:
const userRole = await db.getUserRole(clerkId)
if (!['team_admin', 'admin', 'super_admin'].includes(userRole)) {
return new Response(JSON.stringify({ error: 'Insufficient permissions' }), {
status: 403,
})
}

No Ownership Model:

  • Products are not attributed to specific users (no created_by field)
  • Any admin can edit any product
  • Simplifies product management across teams
  • Suitable for shared catalog systems

Create a new product record.

Security: Auth + Role Check + Rate Limit + CSRF + Validation

Request (JSON):

{
"product_name": "Classic Hoodie",
"product_description": "Comfortable hoodie",
"image_url": "https://example.com/hoodie.jpg",
"categories": ["apparel"],
"sizes": ["s", "m", "l", "xl"],
"colors": ["light", "dark"],
"in_stock": true,
"quantity": 50,
"price": 49.99,
"genders": ["male", "female", "unisex"],
"csrfToken": "token-here"
}

Response (Success):

{
"success": true,
"productId": "uuid-string"
}

Retrieve a single product by ID.

Security: Auth

Response (Success):

{
"success": true,
"product": {
"id": "uuid",
"product_name": "Classic Hoodie",
"product_description": "Comfortable hoodie",
"image_url": "https://example.com/hoodie.jpg",
"categories": ["apparel"],
"sizes": ["s", "m", "l", "xl"],
"colors": ["light", "dark"],
"in_stock": true,
"quantity": 50,
"price": 49.99,
"genders": ["male", "female", "unisex"],
"created_at": "2026-01-24T10:30:00Z",
"updated_at": "2026-01-24T10:30:00Z"
}
}

Update an existing product record.

Security: Auth + Role Check + Rate Limit + CSRF + Validation

Request (JSON):

{
"price": 39.99,
"quantity": 25,
"in_stock": true,
"csrfToken": "token-here"
}

Response (Success):

{
"success": true,
"productId": "uuid-string"
}

For large result sets, select only needed fields:

// ✅ FAST - Minimal data transfer
const products = await db.getProducts({
fields: ['id', 'product_name', 'price', 'image_url'],
limit: 100,
})
// ⚠️ SLOWER - All fields returned
const products = await db.getProducts({
limit: 100,
})

Always use pagination for lists:

// ✅ GOOD - Limited result set
const products = await db.getProducts({
limit: 10,
offset: 0,
})
// ❌ BAD - Fetches all records
const allProducts = await db.getProducts({
limit: 999999,
})

Use indexed fields for filtering and sorting:

// ✅ FAST - Uses GIN index on categories
const apparelProducts = await db.getProducts({
categories: ['apparel'],
})
// ✅ FAST - Uses GIN index on genders
const mensProducts = await db.getProducts({
genders: ['male'],
})
// ✅ FAST - Uses idx_products_in_stock
const inStock = await db.getProducts({
in_stock: true,
})

Symptom: Product created successfully but not visible in queries

Cause: Possibly missing authentication or database sync issue

Solution: Ensure JWT token is present in request headers and user exists in database

Symptom: Insufficient permissions error on create/update/delete

Cause: User does not have required role (team_admin/admin/super_admin)

Solution: Verify user role in database matches required permissions

Symptom: Invalid input error on form submission

Cause: Field doesn’t meet validation requirements

Solution: Check validation rules table above and error details

Symptom: Categories or genders not saving correctly

Cause: Provider differences (JSONB vs JSON TEXT)

Solution: Arrays are automatically handled by database abstraction layer - ensure you’re passing JavaScript arrays (not strings)

Example:

// ✅ CORRECT - JavaScript array
categories: ['apparel', 'accessories']
// ❌ INCORRECT - String (will be rejected)
categories: "apparel,accessories"

Symptom: Price validation fails for values like 19.999

Cause: Price must have at most 2 decimal places

Solution: Round price to 2 decimals before submission:

const price = Math.round(inputPrice * 100) / 100 // Rounds to 2 decimals

Technical Reference:

Database Documentation:

Migration History: