The JSON services approach provides a simple, type-safe way to associate predefined services with events using a JSON array stored directly in the events table. This approach eliminates the need for JOIN queries and provides excellent performance with minimal setup.

🎯 Simple Setup

No database setup required - start using services immediately with the predefined catalog in services.config.ts.

⚡ Type Safe

Compile-time validation ensures only valid service IDs can be used throughout your application.

🚀 Fast Queries

JSONB with GIN indexing (Supabase) provides sub-20ms queries for filtering events by service.

🔄 Coexists with M2M

Works alongside M2M architecture for gradual migration - choose the right approach per event.

Creating an event with JSON services is straightforward:

import { getDatabase } from '#libs/database'
import type { EventData } from '#libs/database-types'
const db = getDatabase()
const eventData: EventData = {
name: 'Community Outreach Event',
event_date: '2026-02-15',
location: 'Downtown Park',
description: 'Monthly community services fair',
start_time: '09:00',
duration: 180,
services: ['haircuts', 'showers', 'clothing'], // JSON array of service IDs
created_by: userId,
}
const eventId = await db.insertEvent(eventData)

The services array is stored as JSONB (Supabase) or TEXT (Turso) in the database and validated against the predefined service catalog.

Services are defined in src/constants/services.config.ts as a readonly array:

Service IDNameDescription
haircutsHaircutsProfessional haircutting services
showersShowersShower facilities and maintenance
clothingClothingProfessional clothing and wardrobe services
videographyVideographyProfessional video recording and editing

Adding New Services:

Edit services.config.ts to add new services to the catalog:

src/constants/services.config.ts
export const SERVICES = [
{
id: 'haircuts',
name: 'Haircuts',
description: 'Professional haircutting services',
displayOrder: 1,
active: true,
},
{
id: 'meals', // New service
name: 'Meals',
description: 'Hot meal service',
displayOrder: 5,
active: true,
},
// ... other services
] as const satisfies readonly ServiceDefinition[]

The ServiceId type automatically updates to include the new service, providing compile-time validation throughout your application.

The EventFormRHF component renders service checkboxes using the active service catalog:

import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { eventSchema, type EventFormData } from '#schemas/event.schema'
import { getActiveServices } from '#constants/services.config'
export const EventFormRHF: React.FC<Props> = ({ csrfToken, event }) => {
const activeServices = getActiveServices()
const { register, handleSubmit, control, formState: { errors } } = useForm<EventFormData>({
resolver: zodResolver(eventSchema),
defaultValues: {
services: event?.services ?? [], // Load existing services
// ... other fields
},
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Service checkboxes */}
<fieldset>
<legend>Services Offered</legend>
<Controller
name="services"
control={control}
render={({ field }) => (
<div>
{activeServices.map(service => (
<label key={service.id}>
<input
type="checkbox"
value={service.id}
checked={field.value?.includes(service.id)}
onChange={e => {
const current = field.value || []
const newValue = e.target.checked
? [...current, service.id]
: current.filter(id => id !== service.id)
field.onChange(newValue)
}}
/>
<span>{service.name}</span>
<small>{service.description}</small>
</label>
))}
</div>
)}
/>
{errors.services && <span>{errors.services.message}</span>}
</fieldset>
<button type="submit">Create Event</button>
</form>
)
}

The form automatically validates service IDs using Zod:

// Validation occurs when form is submitted
const result = eventSchema.safeParse(formData)
if (!result.success) {
// Error: "One or more service IDs are invalid or inactive"
console.error(result.error.flatten())
}

Invalid or inactive service IDs are rejected before reaching the API.

Send a POST request to /api/events/create with services in the request body:

const response = await fetch('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Community Outreach',
event_date: '2026-02-15',
location: 'Downtown Park',
services: ['haircuts', 'showers', 'clothing'],
csrf_token: csrfToken,
}),
})
if (!response.ok) {
const error = await response.json()
console.error('Event creation failed:', error)
return
}
const result = await response.json()
// { success: true, eventId: "550e8400-..." }

Required Fields:

  • name (string): Event name
  • event_date (string): Date in YYYY-MM-DD format
  • csrf_token (string): CSRF protection token

Optional Fields:

  • services (string[]): Array of service IDs
  • location (string): Event location
  • description (string): Event description
  • start_time (string): Time in HH:MM format
  • duration (number): Duration in minutes

Validation Errors (400):

{
"error": "Validation failed",
"details": {
"fieldErrors": {
"services": ["One or more service IDs are invalid or inactive"]
}
}
}

Authentication Errors (401):

{
"error": "Unauthorized"
}

Display validation errors inline in the form next to the services field.

Display resolved service names and descriptions for an event:

import { resolveServices } from '#constants/services.config'
import type { Event } from '#libs/database-types'
type Props = {
event: Event
}
export const EventServiceList: React.FC<Props> = ({ event }) => {
const services = resolveServices(event.services ?? [])
if (services.length === 0) {
return <p>No services offered</p>
}
return (
<ul>
{services.map(service => (
<li key={service.id}>
<strong>{service.name}</strong>
<span>{service.description}</span>
</li>
))}
</ul>
)
}

The resolveServices() utility converts service IDs to full definitions:

import { resolveServices } from '#constants/services.config'
const event = await db.getEvent(eventId)
const services = resolveServices(event.services ?? [])
// services = [
// { id: 'haircuts', name: 'Haircuts', description: '...', ... },
// { id: 'showers', name: 'Showers', description: '...', ... },
// ]
// Display as comma-separated list
const serviceNames = services.map(s => s.name).join(', ')
// "Haircuts, Showers, Clothing"

Graceful Degradation:

  • Inactive services are filtered out silently
  • Unknown service IDs are ignored (no errors thrown)
  • Ensures UI never displays invalid services

Choose the right approach for your use case:

CriterionJSON ServicesM2M Architecture
SetupZero - use config fileRequires DB records
FlexibilityFixed catalog in codeDynamic via admin UI
Performance (Supabase)Excellent (~10-20ms)Good (~15-30ms)
Performance (Turso)Moderate (~50-100ms)Good (~15-30ms)
Type SafetyCompile-timeRuntime
Service MetadataID + name onlyFull (capacity, status)
Best ForPredefined catalogsDynamic catalogs

✅ Choose JSON when:

  • Services are predefined and rarely change
  • You need simple categorization (no capacity/pricing)
  • You want maximum performance on Supabase
  • You prefer code-based configuration over admin UI
  • Your team is comfortable editing TypeScript

Example Scenarios:

  • Event categories (workshops, meals, clothing drives)
  • Appointment types (consultation, followup, emergency)
  • Resource types (projector, whiteboard, microphone)

✅ Choose M2M when:

  • Services are added dynamically by admins
  • You need rich metadata (capacity, pricing, images)
  • You have per-service configuration needs
  • You want an admin UI for service management
  • You need analytics on service usage

Example Scenarios:

  • Marketplace with seller-defined services
  • Healthcare with specialty departments
  • Event venues with configurable amenities

Both systems coexist - you don’t have to choose one exclusively:

  • New events: Use JSON services for simplicity
  • Legacy events: Keep M2M if already implemented
  • Gradual migration: Move events as they’re updated
  • Hybrid approach: Use both based on event type

Supabase (PostgreSQL):

  • JSONB Type: Native binary JSON with operator support
  • GIN Index: Fast containment queries (services @> '["haircuts"]')
  • Query Time: ~10-20ms for service filtering
  • Scaling: Excellent - index remains effective at 1M+ events

Turso (LibSQL):

  • TEXT Type: JSON stored as string (no native support)
  • No Indexing: Full table scans for service queries
  • Query Time: ~50-100ms for service filtering
  • Scaling: Moderate - use date filters to limit scans

For Supabase:

  1. Use JSONB containment operator: services @> '["haircuts"]'::jsonb
  2. GIN index automatically optimizes queries
  3. Single-service queries are fastest (~10ms)

For Turso:

  1. Combine service filter with date range: WHERE event_date >= ? AND services LIKE ?
  2. Use pagination: LIMIT 50 to reduce result size
  3. Consider M2M for datasets > 50K events

Efficient service query (Supabase):

// Abstraction layer handles JSONB operator
const haircutEvents = await db.getEventsByService('haircuts')
// Uses: WHERE services @> '["haircuts"]'::jsonb
// Result: ~10-20ms with GIN index

Date-filtered query (Turso):

// Combine filters to reduce scan size
const upcomingHaircutEvents = await db.getEvents({
from_date: new Date().toISOString().split('T')[0],
service: 'haircuts',
})
// Uses: WHERE event_date >= ? AND services LIKE '%"haircuts"%'
// Result: ~30-60ms (reduced from 50-100ms full scan)
import { getDatabase } from '#libs/database'
import { getActiveServices, resolveServices } from '#constants/services.config'
import type { EventData } from '#libs/database-types'
async function createEventWithServices(userId: string) {
const db = getDatabase()
// 1. Get active services for form display
const activeServices = getActiveServices()
console.log('Available services:', activeServices.map(s => s.name))
// 2. Create event with selected services
const eventData: EventData = {
name: 'Community Outreach',
event_date: '2026-02-15',
location: 'Downtown Park',
services: ['haircuts', 'showers', 'clothing'],
created_by: userId,
}
const eventId = await db.insertEvent(eventData)
// 3. Retrieve and display created event
const event = await db.getEvent(eventId)
const services = resolveServices(event.services ?? [])
console.log(`Event created: ${event.name}`)
console.log('Services:', services.map(s => s.name).join(', '))
return event
}
import { validateServiceIds } from '#constants/services.config'
// Validate before sending to API
function validateFormData(services: string[]) {
const invalidIds = validateServiceIds(services)
if (invalidIds.length > 0) {
throw new Error(
`Invalid service IDs: ${invalidIds.join(', ')}`
)
}
return true
}
// Usage
const userSelection = ['haircuts', 'invalid', 'showers']
try {
validateFormData(userSelection)
} catch (error) {
console.error(error.message)
// "Invalid service IDs: invalid"
}
import { getDatabase } from '#libs/database'
import { resolveServices } from '#constants/services.config'
async function findEventsOfferingService(serviceId: string) {
const db = getDatabase()
// Database abstraction handles provider differences
const events = await db.getEventsByService(serviceId)
// Resolve services for display
const eventsWithServices = events.map(event => ({
...event,
resolvedServices: resolveServices(event.services ?? []),
}))
return eventsWithServices
}
// Usage
const haircutEvents = await findEventsOfferingService('haircuts')
console.log(`Found ${haircutEvents.length} events offering haircuts`)
import { getDatabase } from '#libs/database'
async function findEventsWithAllServices(serviceIds: string[]) {
const db = getDatabase()
// Get all events (or use date filter for performance)
const allEvents = await db.getEvents({
from_date: new Date().toISOString().split('T')[0],
})
// Filter in application layer for multiple services
const filtered = allEvents.filter(event => {
const eventServices = event.services ?? []
return serviceIds.every(id => eventServices.includes(id))
})
return filtered
}
// Usage: Find events offering both haircuts AND showers
const events = await findEventsWithAllServices(['haircuts', 'showers'])
// ✅ Good - Use validation utilities
import { validateServiceIds, resolveServices } from '#constants/services.config'
const invalidIds = validateServiceIds(formData.services)
if (invalidIds.length > 0) {
throw new Error('Invalid services selected')
}
// ❌ Bad - Manual validation
const services = formData.services.filter(id =>
['haircuts', 'showers'].includes(id) // Hard-coded, out of sync
)

2. Graceful Degradation with resolveServices

Section titled “2. Graceful Degradation with resolveServices”
// ✅ Good - Graceful degradation
const services = resolveServices(event.services ?? [])
// Filters out inactive/unknown services silently
// ❌ Bad - Fail on unknown services
const services = event.services.map(id => {
const service = getServiceById(id)
if (!service) throw new Error(`Unknown service: ${id}`)
return service
})

3. Combine Filters for Performance (Turso)

Section titled “3. Combine Filters for Performance (Turso)”
// ✅ Good - Date filter reduces scan size
const events = await db.getEvents({
from_date: '2026-01-01',
to_date: '2026-12-31',
service: 'haircuts',
})
// ❌ Bad - Full table scan
const allEvents = await db.getEvents()
const filtered = allEvents.filter(e =>
e.services?.includes('haircuts')
)
// ✅ Good - Import ServiceId type
import type { ServiceId } from '#constants/services.config'
const selectedServices: ServiceId[] = ['haircuts', 'showers'] // Type-checked
// ❌ Bad - Plain strings
const selectedServices: string[] = ['haircuts', 'invalid'] // No type checking
  1. Review Service Catalog: Check services.config.ts for available services
  2. Create Your First Event: Use EventFormRHF component or API directly
  3. Display Services: Use EventServiceList component in event detail pages
  4. Monitor Performance: Check query times and optimize as needed
  5. Consider Migration: Evaluate JSON vs M2M for your specific use case