Skip to content

Complete CRUD Example

Full CRUD implementation with pagination and search.


Overview

Building a Products feature with:

  • List with pagination
  • Search/filter
  • Create, Edit, Delete
  • Form validation

Database Schema

typescript
// Add to DatabaseSchema
products: {
  id: string
  name: string
  description: string | null
  price: number
  stock: number
  category: string
  created_at: string
  updated_at: string
}

Repository

typescript
// src/features/products/repository.ts
import { db } from '../_core/database/connection'
import { uuidv7 } from '../../shared/lib/uuid'

export class ProductRepository {
  async findAll(options: { 
    page?: number
    limit?: number
    search?: string
    category?: string
  } = {}) {
    const { page = 1, limit = 10, search, category } = options
    
    let query = db.selectFrom('products')
    
    if (search) {
      query = query.where('name', 'like', `%${search}%`)
    }
    
    if (category) {
      query = query.where('category', '=', category)
    }
    
    const total = await query
      .select(db.fn.count('id').as('count'))
      .executeTakeFirst()
    
    const items = await query
      .selectAll()
      .limit(limit)
      .offset((page - 1) * limit)
      .execute()
    
    return {
      items,
      total: Number(total?.count || 0),
      page,
      limit,
      totalPages: Math.ceil(Number(total?.count || 0) / limit)
    }
  }
  
  async findById(id: string) {
    return db.selectFrom('products')
      .where('id', '=', id)
      .selectAll()
      .executeTakeFirst()
  }
  
  async create(data: { name: string; price: number; stock: number; category: string }) {
    const id = uuidv7()
    const now = new Date().toISOString()
    
    return db.insertInto('products')
      .values({ ...data, id, description: null, created_at: now, updated_at: now })
      .returningAll()
      .executeTakeFirst()
  }
  
  async update(id: string, data: Partial<Record<string, any>>) {
    return db.updateTable('products')
      .set({ ...data, updated_at: new Date().toISOString() })
      .where('id', '=', id)
      .returningAll()
      .executeTakeFirst()
  }
  
  async delete(id: string) {
    return db.deleteFrom('products').where('id', '=', id).execute()
  }
  
  async getCategories() {
    const result = await db.selectFrom('products')
      .select('category')
      .distinct()
      .execute()
    return result.map(r => r.category)
  }
}

Service

typescript
// src/features/products/service.ts
import { t, type Static } from 'elysia'
import { ProductRepository } from './repository'

export const CreateProductSchema = t.Object({
  name: t.String({ minLength: 1, maxLength: 255 }),
  price: t.Number({ minimum: 0 }),
  stock: t.Number({ minimum: 0 }),
  category: t.String({ minLength: 1 })
})

export const UpdateProductSchema = t.Partial(CreateProductSchema)

export type CreateProductPayload = Static<typeof CreateProductSchema>

export class ProductService {
  constructor(private repo: ProductRepository = new ProductRepository()) {}
  
  async getAll(options: Parameters<ProductRepository['findAll']>[0]) {
    return this.repo.findAll(options)
  }
  
  async getById(id: string) {
    return this.repo.findById(id)
  }
  
  async create(payload: CreateProductPayload) {
    return this.repo.create(payload)
  }
  
  async update(id: string, payload: any) {
    return this.repo.update(id, payload)
  }
  
  async delete(id: string) {
    return this.repo.delete(id)
  }
  
  async getCategories() {
    return this.repo.getCategories()
  }
}

API Routes

typescript
// src/features/products/api.ts
import { Elysia } from 'elysia'
import { authApi } from '../_core/auth/api'
import { ProductService, CreateProductSchema, UpdateProductSchema } from './service'
import { inertia, type Inertia } from '../../inertia/plugin'

export const productApi = new Elysia({ prefix: '/products' })
  .use(authApi)
  .auth(true)
  .use(inertia())
  .derive(() => ({ productService: new ProductService() }))
  
  // List with pagination
  .get('/', async (ctx) => {
    const { inertia, productService, query } = ctx as any
    
    const page = Number(query.page) || 1
    const search = query.search || ''
    const category = query.category || ''
    
    const [result, categories] = await Promise.all([
      productService.getAll({ page, limit: 10, search, category }),
      productService.getCategories()
    ])
    
    return inertia.render('products/Index', {
      ...result,
      categories,
      filters: { search, category }
    })
  })
  
  // Create form
  .get('/create', async (ctx) => {
    const { inertia, productService } = ctx as any
    const categories = await productService.getCategories()
    return inertia.render('products/Create', { categories, errors: {} })
  })
  
  // Store
  .post('/', async (ctx) => {
    const { body, productService, inertia } = ctx as any
    await productService.create(body)
    return inertia.redirect('/products')
  }, { body: CreateProductSchema })
  
  // Edit form
  .get('/:id/edit', async (ctx) => {
    const { params, productService, inertia } = ctx as any
    const [product, categories] = await Promise.all([
      productService.getById(params.id),
      productService.getCategories()
    ])
    return inertia.render('products/Edit', { product, categories, errors: {} })
  })
  
  // Update
  .put('/:id', async (ctx) => {
    const { params, body, productService, inertia } = ctx as any
    await productService.update(params.id, body)
    return inertia.redirect('/products')
  }, { body: UpdateProductSchema })
  
  // Delete
  .delete('/:id', async (ctx) => {
    const { params, productService, inertia } = ctx as any
    await productService.delete(params.id)
    return inertia.redirect('/products')
  })

Index Page (with Pagination)

svelte
<!-- src/features/products/pages/Index.svelte -->
<script lang="ts">
  import { router } from '@inertiajs/svelte'
  import { Plus, Pencil, Trash, ChevronLeft, ChevronRight } from 'lucide-svelte'
  
  interface Props {
    items: Array<{
      id: string
      name: string
      price: number
      stock: number
      category: string
    }>
    total: number
    page: number
    limit: number
    totalPages: number
    categories: string[]
    filters: { search: string; category: string }
  }
  
  let { items, total, page, totalPages, categories, filters }: Props = $props()
  
  let searchQuery = $state(filters.search)
  let selectedCategory = $state(filters.category)
  
  function applyFilters() {
    const params = new URLSearchParams()
    if (searchQuery) params.set('search', searchQuery)
    if (selectedCategory) params.set('category', selectedCategory)
    router.get(`/products?${params.toString()}`)
  }
  
  function goToPage(newPage: number) {
    const params = new URLSearchParams(window.location.search)
    params.set('page', newPage.toString())
    router.get(`/products?${params.toString()}`)
  }
  
  function deleteProduct(id: string) {
    if (confirm('Delete this product?')) {
      router.delete(`/products/${id}`)
    }
  }
  
  function formatPrice(price: number) {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD'
    }).format(price)
  }
</script>

<div class="p-6 max-w-6xl mx-auto">
  <div class="flex justify-between items-center mb-6">
    <h1 class="text-2xl font-bold text-slate-900 dark:text-white">
      Products ({total})
    </h1>
    <a href="/products/create" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
      <Plus class="w-4 h-4" />
      Add Product
    </a>
  </div>
  
  <!-- Filters -->
  <div class="flex gap-4 mb-6">
    <input
      type="text"
      placeholder="Search products..."
      bind:value={searchQuery}
      onchange={applyFilters}
      class="px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
    />
    
    <select
      bind:value={selectedCategory}
      onchange={applyFilters}
      class="px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
    >
      <option value="">All Categories</option>
      {#each categories as cat}
        <option value={cat}>{cat}</option>
      {/each}
    </select>
  </div>
  
  <!-- Table -->
  <div class="bg-white dark:bg-slate-800 rounded-lg shadow overflow-hidden">
    <table class="w-full">
      <thead class="bg-slate-50 dark:bg-slate-700">
        <tr>
          <th class="px-4 py-3 text-left">Name</th>
          <th class="px-4 py-3 text-left">Category</th>
          <th class="px-4 py-3 text-right">Price</th>
          <th class="px-4 py-3 text-right">Stock</th>
          <th class="px-4 py-3 text-right">Actions</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-slate-200 dark:divide-slate-700">
        {#each items as product}
          <tr>
            <td class="px-4 py-3 text-slate-900 dark:text-white">{product.name}</td>
            <td class="px-4 py-3">
              <span class="px-2 py-1 text-xs rounded-full bg-slate-100 dark:bg-slate-700">
                {product.category}
              </span>
            </td>
            <td class="px-4 py-3 text-right">{formatPrice(product.price)}</td>
            <td class="px-4 py-3 text-right">
              <span class={product.stock < 10 ? 'text-red-600' : ''}>
                {product.stock}
              </span>
            </td>
            <td class="px-4 py-3 text-right">
              <div class="flex justify-end gap-2">
                <a href="/products/{product.id}/edit" class="p-1 text-slate-600 hover:text-indigo-600">
                  <Pencil class="w-4 h-4" />
                </a>
                <button onclick={() => deleteProduct(product.id)} class="p-1 text-slate-600 hover:text-red-600">
                  <Trash class="w-4 h-4" />
                </button>
              </div>
            </td>
          </tr>
        {/each}
      </tbody>
    </table>
    
    {#if items.length === 0}
      <div class="p-8 text-center text-slate-500">No products found</div>
    {/if}
  </div>
  
  <!-- Pagination -->
  {#if totalPages > 1}
    <div class="flex justify-center items-center gap-2 mt-6">
      <button
        disabled={page <= 1}
        onclick={() => goToPage(page - 1)}
        class="p-2 border rounded-lg disabled:opacity-50"
      >
        <ChevronLeft class="w-4 h-4" />
      </button>
      
      <span class="text-slate-600 dark:text-slate-400">
        Page {page} of {totalPages}
      </span>
      
      <button
        disabled={page >= totalPages}
        onclick={() => goToPage(page + 1)}
        class="p-2 border rounded-lg disabled:opacity-50"
      >
        <ChevronRight class="w-4 h-4" />
      </button>
    </div>
  {/if}
</div>

Released under the MIT License.