Patterns

Table Patterns

Working with tables in Nuxt Crouton - composition, configuration, pagination, and related data

Table Patterns

Query Examples: For complete useCollectionQuery patterns (basic, filtering, pagination, sorting, relations), see Querying Data.

Learn how to work with Nuxt Crouton's table system - from basic composition to advanced configurations and displaying related data.

For general table concepts and Nuxt UI table components, see the Nuxt UI Table documentation.
Complete CroutonTable API Reference: For comprehensive table component documentation including all props, slots, and events, see Table Components API Reference.

Table Component Architecture

Nuxt Crouton provides specialized components that work together to create feature-rich data tables:

ComponentPurposeKey Features
TableHeaderNavigation bar with title and create buttonCollection formatting, responsive labels, modal integration
TableSearchDebounced search inputv-model support, configurable debounce, search icon
TableActionsBulk operations (delete, column visibility)Row selection state, delete confirmation, column toggles
TablePaginationPage navigation and size controlsPage range display, loading states, i18n support

Component Layout

<UDashboardPanel>
  <!-- 1. Header Section -->
  <template #header>
    <TableHeader :collection="collection" :create-button="true" />
  </template>

  <!-- 2. Body Section -->
  <template #body>
    <!-- 2a. Controls Row -->
    <div class="flex justify-between">
      <TableSearch v-model="search" />
      <TableActions
        :selected-rows="selectedRows"
        :collection="collection"
        :table="tableRef"
        @delete="handleDelete"
      />
    </div>

    <!-- 2b. Data Table -->
    <UTable
      v-model:row-selection="selectedRows"
      ref="tableRef"
      :data="rows"
      :columns="columns"
      :loading="loading"
    />

    <!-- 2c. Footer Controls -->
    <TablePagination
      :page="page"
      :page-count="pageCount"
      :total-items="totalItems"
      :loading="loading"
      @update:page="page = $event"
      @update:page-count="handlePageCountChange"
    />
  </template>
</UDashboardPanel>

Basic Table Setup

Progressive Examples

Basic Table

<script setup lang="ts">
const columns = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'email', header: 'Email' }
]

const { data } = await useCollectionQuery({ collection: 'users' })
const rows = computed(() => data.value?.items || [])
</script>

<template>
  <UTable :data="rows" :columns="columns" />
</template>
<script setup lang="ts">
const search = ref('')

const { data } = await useCollectionQuery({
  collection: 'users',
  filter: computed(() => ({ search: search.value }))
})
</script>

<template>
  <TableSearch v-model="search" placeholder="Search users..." />
  <UTable :data="rows" :columns="columns" />
</template>

With Pagination

<script setup lang="ts">
const page = ref(1)
const pageCount = ref(10)

const { data, refresh } = await useCollectionQuery({
  collection: 'users',
  pagination: computed(() => ({
    currentPage: page.value,
    pageSize: pageCount.value
  }))
})

const rows = computed(() => data.value?.items || [])
const totalItems = computed(() => data.value?.pagination?.totalItems || 0)

async function handlePageChange(newPage: number) {
  page.value = newPage
  await refresh()
}
</script>

<template>
  <UTable :data="rows" :columns="columns" />
  <TablePagination
    :page="page"
    :page-count="pageCount"
    :total-items="totalItems"
    @update:page="handlePageChange"
  />
</template>

Complete with Actions

<script setup lang="ts">
const collection = 'users'
const selectedRows = ref([])
const tableRef = ref()

async function handleDelete(ids: string[]) {
  selectedRows.value = []
  await refresh()
}
</script>

<template>
  <UDashboardPanel>
    <template #header>
      <TableHeader :collection="collection" :create-button="true" />
    </template>

    <template #body>
      <TableActions
        :selected-rows="selectedRows"
        :collection="collection"
        :table="tableRef"
        @delete="handleDelete"
      />

      <UTable
        v-model:row-selection="selectedRows"
        ref="tableRef"
        :data="rows"
        :columns="columns"
      />
    </template>
  </UDashboardPanel>
</template>

There are two main approaches to displaying related data in tables, each with different trade-offs.

Option 1: Fetch Separately (Simple)

Best for: Small datasets, simple apps, prototyping

Fetch both collections separately and map them in the component:

<script setup lang="ts">
const { items: products } = await useCollectionQuery('shopProducts')
const { items: categories } = await useCollectionQuery('shopCategories')

// Map categories by ID for quick lookup
const categoryMap = computed(() =>
  Object.fromEntries(categories.value.map(c => [c.id, c]))
)

const columns = [
  { key: 'name', label: 'Product' },
  { key: 'price', label: 'Price' },
  {
    key: 'category',
    label: 'Category',
    // Look up category name
    render: (row) => categoryMap.value[row.categoryId]?.name || 'N/A'
  }
]
</script>

<template>
  <CroutonList :rows="products" :columns="columns" />
</template>
Object Mapping: Learn more about object mapping with computed() in the Vue documentation.

Advantages:

  • Simple to implement
  • Uses existing collection queries
  • Works well for small datasets
  • Easy to understand and debug

Disadvantages:

  • Two separate queries
  • All categories loaded even if not used
  • Not ideal for large datasets

Option 2: Server-Side Join (Efficient)

Best for: Large datasets, performance-critical apps, complex filtering

Create a custom API endpoint that joins the data on the server:

// server/api/teams/[team]/shop-products-with-category.get.ts
import { db } from '~/server/database'
import { shopProducts, shopCategories } from '~/layers/shop/server/database/schema'

export default defineEventHandler(async (event) => {
  const teamId = getRouterParam(event, 'team')

  // Drizzle relations query (if you set up relations)
  const products = await db.query.shopProducts.findMany({
    where: eq(shopProducts.teamId, teamId),
    with: { category: true }  // Join automatically
  })

  return products
})

Use in your component:

<script setup lang="ts">
// Custom endpoint with joined data
const { data: products } = await useFetch('/api/teams/current/shop-products-with-category')

const columns = [
  { key: 'name', label: 'Product' },
  { key: 'price', label: 'Price' },
  {
    key: 'category.name',  // Access nested data
    label: 'Category'
  }
]
</script>

<template>
  <CroutonList :rows="products" :columns="columns" />
</template>

Advantages:

  • Single query (efficient)
  • Scales to large datasets
  • Can filter by related fields
  • Better performance

Disadvantages:

  • More setup required
  • Needs Drizzle relations configured
  • Custom API endpoint to maintain

When to Use Each Approach

Use Option 1 (Fetch Separately) When:

  • You have less than 100 items
  • Relations are optional/occasional
  • You're prototyping or learning
  • Simplicity is more important than performance

Use Option 2 (Server-Side Join) When:

  • You have hundreds or thousands of items
  • You need to filter by related fields
  • Performance is critical
  • You're fetching related data frequently

Rule of thumb: Start with Option 1, migrate to Option 2 when you encounter performance issues.

Pagination Strategies

Complete Pagination Guide: For step-by-step instructions on adding server-side pagination to generated collections, see the Pagination Guide.

Client-Side Pagination (Default)

Best for small datasets (< 1000 items). All data loads at once, pagination happens in the browser:

<script setup lang="ts">
const { items, pending } = await useCollectionQuery('shopProducts')
const { columns } = useShopProducts()
</script>

<template>
  <CroutonList
    :rows="items"
    :columns="columns"
    layout="table"
  />
  <!-- Pagination handled automatically in browser -->
</template>

Pros: Instant pagination, no API calls, offline-capable Cons: Slow initial load for large datasets, high memory usage

Server-Side Pagination

Best for large datasets (> 1000 items). Only loads one page at a time:

<script setup lang="ts">
const page = ref(1)
const pageSize = ref(25)

const { items, pending, refresh } = await useCollectionQuery('shopProducts', {
  query: computed(() => ({
    page: page.value,
    pageSize: pageSize.value
  }))
})

const { columns } = useShopProducts()

// Pagination data from server
const paginationData = computed(() => ({
  currentPage: page.value,
  pageSize: pageSize.value,
  totalItems: 10000, // From your API
  totalPages: 400
}))
</script>

<template>
  <CroutonList
    :rows="items"
    :columns="columns"
    layout="table"
    server-pagination
    :pagination-data="paginationData"
    :refresh-fn="refresh"
  />
</template>

Pros: Fast initial load, low memory, scalable Cons: Network latency on page changes

API Implementation for Server Pagination

// server/api/teams/[team]/shop-products/index.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const pageSize = Number(query.pageSize) || 25
  const offset = (page - 1) * pageSize

  const [items, totalCount] = await Promise.all([
    db.select()
      .from(products)
      .limit(pageSize)
      .offset(offset),
    db.select({ count: count() })
      .from(products)
      .then(r => r[0].count)
  ])

  return {
    items,
    pagination: {
      currentPage: page,
      pageSize,
      totalItems: totalCount,
      totalPages: Math.ceil(totalCount / pageSize)
    }
  }
})

Advanced Configuration

Column Visibility Toggle

Users can show/hide columns dynamically:

<script setup lang="ts">
const columnVisibility = ref({
  id: false,          // Hide ID column by default
  sku: true,
  price: true,
  category: true
})
</script>

<template>
  <CroutonList
    v-model:column-visibility="columnVisibility"
    :rows="items"
    :columns="columns"
  />
</template>

The table toolbar automatically includes a column visibility menu.

Hiding Default Columns

Tables include four default columns: created_at, updated_at, updatedBy, and actions. Hide them selectively:

<template>
  <CroutonList
    :rows="items"
    :columns="columns"
    :hide-default-columns="{
      created_at: true,  // Hide creation date
      updated_at: true,  // Hide update date
      updatedBy: false,  // Show updated by user (default: shown)
      actions: false     // Show actions (edit/delete buttons)
    }"
  />
</template>

Row Selection & Bulk Operations

Enable bulk operations with row selection:

<script setup lang="ts">
const selectedRows = ref([])
const { deleteItems } = useCollectionMutation('shopProducts')

const handleBulkDelete = async () => {
  const ids = selectedRows.value.map(row => row.id)
  await deleteItems(ids)
  selectedRows.value = []
}
</script>

<template>
  <div>
    <CroutonList
      v-model:selected="selectedRows"
      :rows="items"
      :columns="columns"
      selectable
    />

    <UButton
      v-if="selectedRows.length > 0"
      @click="handleBulkDelete"
      color="red"
    >
      Delete {{ selectedRows.length }} items
    </UButton>
  </div>
</template>

Sorting

Client-Side Sorting:

<script setup lang="ts">
const columns = [
  { key: 'name', label: 'Name', sortable: true },
  { key: 'price', label: 'Price', sortable: true },
  { key: 'category', label: 'Category', sortable: false }
]
</script>

Server-Side Sorting:

<script setup lang="ts">
const sortBy = ref('createdAt')
const sortDirection = ref<'asc' | 'desc'>('desc')

const { items } = await useCollectionQuery('shopProducts', {
  query: computed(() => ({
    sortBy: sortBy.value,
    sortDirection: sortDirection.value
  }))
})

const paginationData = computed(() => ({
  sortBy: sortBy.value,
  sortDirection: sortDirection.value
}))
</script>

<template>
  <CroutonList
    :rows="items"
    :columns="columns"
    server-pagination
    :pagination-data="paginationData"
  />
</template>

Drag-and-Drop Row Reordering

Enable users to reorder table rows by dragging them. This feature uses SortableJS under the hood.

New in v1.7: Drag-and-drop reordering is now available for table layouts.

Basic Usage

Enable drag-and-drop with the sortable prop:

<template>
  <CroutonCollection
    layout="table"
    collection="tasks"
    :rows="items"
    sortable
  />
</template>

This adds a drag handle column and allows users to reorder rows by dragging.

Sortable Options

Pass an object to customize the behavior:

<template>
  <CroutonCollection
    layout="table"
    collection="tasks"
    :rows="items"
    :sortable="{
      handle: true,      // Show drag handle column (default: true)
      animation: 150,    // Animation duration in ms (default: 150)
      disabled: false    // Temporarily disable dragging
    }"
  />
</template>
OptionTypeDefaultDescription
handlebooleantrueShow drag handle column with grip icon
animationnumber150SortableJS animation duration in milliseconds
disabledbooleanfalseTemporarily disable drag-and-drop

Requirements

1. Add an order field to your schema:

// server/database/schema.ts
export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey().$default(() => nanoid()),
  title: text('title').notNull(),
  order: integer('order').notNull().$default(() => 0),
  // ... other fields
})

2. Create a reorder API endpoint:

// server/api/teams/[id]/tasks/reorder.patch.ts
import { eq, and } from 'drizzle-orm'
import { tasks } from '~/server/database/schema'
import { resolveTeamAndCheckMembership } from '#crouton/team-auth'

export default defineEventHandler(async (event) => {
  const { team, user } = await resolveTeamAndCheckMembership(event)
  const body = await readBody<{ updates: Array<{ id: string; order: number }> }>(event)

  if (!body.updates || !Array.isArray(body.updates)) {
    throw createError({ statusCode: 400, statusMessage: 'Invalid updates array' })
  }

  const db = useDB()

  await Promise.all(
    body.updates.map(({ id, order }) =>
      db.update(tasks)
        .set({ order, updatedBy: user.id })
        .where(and(eq(tasks.id, id), eq(tasks.teamId, team.id)))
    )
  )

  return { success: true }
})

3. Sort by order in your queries:

// In your query function
import { asc, desc } from 'drizzle-orm'

const items = await db.select()
  .from(tasks)
  .where(eq(tasks.teamId, teamId))
  .orderBy(asc(tasks.order), desc(tasks.createdAt))

How It Works

  1. When sortable is enabled, a drag handle column is added to the table
  2. Users drag rows using the grip icon
  3. On drop, useTreeMutation().reorderSiblings() is called automatically
  4. The API endpoint updates all affected order values
  5. The table refreshes to reflect the new order

Example: Task List with Reordering

<script setup lang="ts">
const { items, pending } = await useCollectionQuery('tasks', {
  query: { projectId: props.projectId }
})
</script>

<template>
  <div class="space-y-4">
    <div class="flex justify-between items-center">
      <h2>Tasks</h2>
      <UButton icon="i-lucide-plus" @click="openCreate">Add Task</UButton>
    </div>

    <CroutonCollection
      v-if="items?.length"
      layout="table"
      collection="tasks"
      :rows="items"
      sortable
      :hide-default-columns="{ createdAt: true, updatedAt: true }"
    />

    <p v-else class="text-muted text-center py-8">
      No tasks yet. Create one to get started.
    </p>
  </div>
</template>
Tip: Drag-and-drop works best with smaller datasets. For large lists (100+ items), consider using server-side pagination and only allowing reordering within the current page.

Advanced Patterns

Server-Side Filtering with UI

Combine search and filters with server-side processing:

For a complete working example demonstrating server-side filtering with a collapsible filter panel, see this interactive demo:

View Full Interactive Demo →Fork the demo to explore advanced filtering patterns. The complete example includes:
  • Collapsible filter panel
  • useCollectionQuery with reactive filters
  • Search + advanced filters combined
  • Filter reset functionality
  • Table components (Header, Search, Actions, Pagination)
  • Server-side processing

Focused Example: Reactive Server-Side Filters

This snippet shows the key pattern for combining search and advanced filters with server-side processing:

<script setup lang="ts">
const showFilters = ref(false)
const filters = ref({
  status: null,
  role: null,
  dateRange: null
})

const { data, pending: loading, refresh } = await useCollectionQuery({
  collection: 'users',
  pagination: computed(() => ({
    currentPage: page.value,
    pageSize: pageCount.value
  })),
  filter: computed(() => ({
    search: search.value,
    ...filters.value
  }))
})

async function applyFilters() {
  page.value = 1 // Reset to first page
  await refresh()
}
</script>

<template>
  <UDashboardPanel>
    <template #header>
      <TableHeader :collection="collection" :create-button="true">
        <template #extraButtons>
          <UButton icon="i-lucide-filter" @click="showFilters = true">
            Filters
          </UButton>
        </template>
      </TableHeader>
    </template>
    <!-- See interactive demo for complete filter panel and table -->
  </UDashboardPanel>
</template>

Custom Bulk Actions

Extend TableActions with custom bulk operations:

<template>
  <div class="flex items-center gap-2">
    <TableActions
      :selected-rows="selectedRows"
      :collection="collection"
      :table="tableRef"
      @delete="handleDelete"
    />

    <template v-if="selectedRows.length > 0">
      <UButton color="green" variant="soft" icon="i-lucide-check-circle" @click="bulkApprove">
        Approve Selected
      </UButton>

      <UButton color="gray" variant="soft" icon="i-lucide-download" @click="bulkExport">
        Export CSV
      </UButton>
    </template>
  </div>
</template>

<script setup lang="ts">
const selectedRows = ref([])

async function bulkApprove() {
  const ids = selectedRows.value.map(row => row.id)
  await $fetch('/api/users/bulk-approve', { method: 'POST', body: { ids } })
  await refresh()
  selectedRows.value = []
}

async function bulkExport() {
  const ids = selectedRows.value.map(row => row.id)
  const csv = await $fetch('/api/users/export', { query: { ids: ids.join(',') } })
  const blob = new Blob([csv], { type: 'text/csv' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'users.csv'
  a.click()
  selectedRows.value = []
}
</script>

Persistent State (URL Query Params)

Persist table state in URL for shareable links:

<script setup lang="ts">
const route = useRoute()
const router = useRouter()

// Initialize from URL
const search = ref(route.query.search as string || '')
const page = ref(Number(route.query.page) || 1)
const pageCount = ref(Number(route.query.limit) || 10)

// Watch for changes and update URL
watch([search, page, pageCount], () => {
  router.push({
    query: {
      ...route.query,
      search: search.value || undefined,
      page: page.value > 1 ? String(page.value) : undefined,
      limit: pageCount.value !== 10 ? String(pageCount.value) : undefined
    }
  })
})

// When user shares URL, table state is preserved
</script>
URL State Sync: Learn more about watch() and Vue Router query parameters in the Vue documentation and Vue Router docs.

Using CroutonTable (All-in-One)

CroutonTable automatically composes all table components for you:

<template>
  <!-- All-in-one approach -->
  <CroutonTable
    :collection="collection"
    :rows="rows"
    :columns="columns"
    :create="true"              <!-- TableHeader with create button -->
    searchable                   <!-- TableSearch included -->
    selection                    <!-- TableActions included -->
    :server-pagination="true"    <!-- TablePagination included -->
    :pagination-data="paginationData"
  />
</template>

When to use CroutonTable vs Manual Composition:

Use CroutonTable WhenUse Manual Composition When
Standard CRUD tablesCustom layouts needed
Rapid prototypingAdvanced filtering UI
Following conventionsCustom bulk actions
Simple data displayComplex state management

Best Practices

State Management

✅ DO: Use computed properties for derived state

const rows = computed(() => data.value?.items || [])
const totalItems = computed(() => data.value?.pagination?.totalItems || 0)

❌ DON'T: Duplicate state

// Bad - duplicates source of truth
const rows = ref([])
watch(data, (newData) => {
  rows.value = newData.items // Unnecessary duplication
})

Pagination Reset

✅ DO: Reset to page 1 when filters change

async function handlePageCountChange(newCount: number) {
  pageCount.value = newCount
  page.value = 1 // Always reset to first page
  await refresh()
}

watch(search, () => {
  page.value = 1 // Reset when search changes
})

❌ DON'T: Stay on current page after filter change

// Bad - might show empty results if page 5 doesn't exist with new filter
watch(search, refresh) // Stays on current page

Search Optimization

✅ DO: Use debounce for search

<TableSearch
  v-model="search"
  :debounce-ms="300"  <!-- Prevents excessive API calls -->
/>

❌ DON'T: Search on every keystroke

// Bad - triggers API on every keystroke
watch(search, async (value) => {
  await $fetch('/api/search', { query: { q: value } })
})

Loading States

✅ DO: Show loading states during operations

const loading = ref(false)

async function handleDelete(ids: string[]) {
  loading.value = true
  try {
    await $fetch('/api/delete', { body: { ids } })
  } finally {
    loading.value = false
  }
}

General Guidelines

✅ DO:

  • Use server pagination for datasets > 1000 items
  • Implement search on the server for better performance
  • Show loading states during data fetches
  • Enable sorting on relevant columns only
  • Hide unnecessary default columns

❌ DON'T:

  • Mix client and server pagination logic
  • Forget to handle loading states
  • Make every column sortable (UX anti-pattern)
  • Skip error handling on refresh
  • Load all data with client pagination if you have 10,000+ items

Performance Tips

Virtualize Large Tables

For tables with 1000+ rows, use virtualization:

<template>
  <UTable
    :data="rows"
    :columns="columns"
    virtual
    :virtual-row-height="48"
  />
</template>

Optimize Search Debounce

Adjust debounce based on operation cost:

<!-- Light operations: 300ms -->
<TableSearch :debounce-ms="300" />

<!-- Heavy API calls: 500-1000ms -->
<TableSearch :debounce-ms="800" />

Use Server-Side Pagination

For large datasets, always use server-side pagination:

const { data } = await useCollectionQuery({
  collection: 'products',
  pagination: {
    currentPage: page.value,
    pageSize: pageCount.value
  }
})
// Only fetches current page, not all items

Troubleshooting

Search not working

Problem: Search input changes but table doesn't update

Solution:

  1. Ensure search is reactive (ref())
  2. Pass as computed to useCollectionQuery
  3. Reset page to 1 when search changes
const search = ref('')

watch(search, () => {
  page.value = 1 // Important!
})

const { data } = await useCollectionQuery({
  filter: computed(() => ({ search: search.value }))
})

Pagination shows wrong range

Problem: "Showing 1-10 of 0 results" even though items exist

Solution: Ensure totalItems reflects the actual total count:

const totalItems = computed(() => {
  return data.value?.pagination?.totalItems || 0
  // NOT: data.value?.items?.length (this is just current page)
})

Delete button always disabled

Problem: Delete button is grayed out even when rows are selected

Solution: Verify selectedRows is a non-empty array:

<UTable
  v-model:row-selection="selectedRows"
  <!-- ... -->
/>

<TableActions
  :selected-rows="selectedRows"  <!-- Must be the same ref -->
/>