Table Patterns
Table Patterns
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.
Table Component Architecture
Nuxt Crouton provides specialized components that work together to create feature-rich data tables:
| Component | Purpose | Key Features |
|---|---|---|
| TableHeader | Navigation bar with title and create button | Collection formatting, responsive labels, modal integration |
| TableSearch | Debounced search input | v-model support, configurable debounce, search icon |
| TableActions | Bulk operations (delete, column visibility) | Row selection state, delete confirmation, column toggles |
| TablePagination | Page navigation and size controls | Page 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>
With Search
<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>
Displaying Related Data
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>
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
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.
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>
| Option | Type | Default | Description |
|---|---|---|---|
handle | boolean | true | Show drag handle column with grip icon |
animation | number | 150 | SortableJS animation duration in milliseconds |
disabled | boolean | false | Temporarily 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
- When
sortableis enabled, a drag handle column is added to the table - Users drag rows using the grip icon
- On drop,
useTreeMutation().reorderSiblings()is called automatically - The API endpoint updates all affected
ordervalues - 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>
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:
- Collapsible filter panel
useCollectionQuerywith 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>
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 When | Use Manual Composition When |
|---|---|
| Standard CRUD tables | Custom layouts needed |
| Rapid prototyping | Advanced filtering UI |
| Following conventions | Custom bulk actions |
| Simple data display | Complex 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:
- Ensure
searchis reactive (ref()) - Pass as computed to
useCollectionQuery - 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 -->
/>
Related Topics
- Pagination Guide - Adding server-side pagination
- Working with Relations
- Form Patterns
- Custom Columns
- Responsive Layouts