Advanced Data Tables

Build enterprise-grade data tables with filtering, sorting, pagination, inline editing, bulk actions, and export capabilities.

Why Advanced Tables

  • ✅ Handle large datasets efficiently
  • ✅ Provide powerful filtering and search
  • ✅ Enable quick inline editing
  • ✅ Support bulk operations
  • ✅ Export data in various formats

Server-Side Pagination & Sorting

Composable for Table State

// app/composables/useDataTable.ts
export interface TableState {
  page: number
  pageSize: number
  sortBy: string | null
  sortOrder: 'asc' | 'desc'
  filters: Record<string, any>
}

export function useDataTable<T>(
  endpoint: string,
  initialState: Partial<TableState> = {}
) {
  const state = ref<TableState>({
    page: 1,
    pageSize: 10,
    sortBy: null,
    sortOrder: 'asc',
    filters: {},
    ...initialState
  })
  
  const data = ref<T[]>([])
  const total = ref(0)
  const loading = ref(false)
  
  const totalPages = computed(() => Math.ceil(total.value / state.value.pageSize))
  
  async function fetchData() {
    loading.value = true
    
    try {
      const params = new URLSearchParams({
        page: state.value.page.toString(),
        pageSize: state.value.pageSize.toString(),
        ...(state.value.sortBy && {
          sortBy: state.value.sortBy,
          sortOrder: state.value.sortOrder
        }),
        ...state.value.filters
      })
      
      const response = await $fetch<{ data: T[], total: number }>(
        `${endpoint}?${params}`
      )
      
      data.value = response.data
      total.value = response.total
    } finally {
      loading.value = false
    }
  }
  
  function setPage(page: number) {
    state.value.page = page
    fetchData()
  }
  
  function setSort(column: string) {
    if (state.value.sortBy === column) {
      state.value.sortOrder = state.value.sortOrder === 'asc' ? 'desc' : 'asc'
    } else {
      state.value.sortBy = column
      state.value.sortOrder = 'asc'
    }
    state.value.page = 1
    fetchData()
  }
  
  function setFilters(filters: Record<string, any>) {
    state.value.filters = filters
    state.value.page = 1
    fetchData()
  }
  
  // Fetch on mount
  onMounted(() => fetchData())
  
  return {
    data: readonly(data),
    total: readonly(total),
    loading: readonly(loading),
    state: readonly(state),
    totalPages,
    setPage,
    setSort,
    setFilters,
    refresh: fetchData
  }
}

Server-Side API Handler

// server/api/users/index.get.ts
import type { H3Event } from 'h3'

export default defineEventHandler(async (event: H3Event) => {
  const query = getQuery(event)
  
  const page = parseInt(query.page as string) || 1
  const pageSize = parseInt(query.pageSize as string) || 10
  const sortBy = query.sortBy as string | undefined
  const sortOrder = query.sortOrder as 'asc' | 'desc' || 'asc'
  
  // Build filters
  const filters: any = {}
  if (query.search) {
    filters.OR = [
      { name: { contains: query.search, mode: 'insensitive' } },
      { email: { contains: query.search, mode: 'insensitive' } }
    ]
  }
  if (query.role) {
    filters.role = query.role
  }
  if (query.status) {
    filters.status = query.status
  }
  
  // Fetch data with pagination
  const [data, total] = await Promise.all([
    db.user.findMany({
      where: filters,
      skip: (page - 1) * pageSize,
      take: pageSize,
      orderBy: sortBy ? { [sortBy]: sortOrder } : { createdAt: 'desc' }
    }),
    db.user.count({ where: filters })
  ])
  
  return {
    data,
    total,
    page,
    pageSize,
    totalPages: Math.ceil(total / pageSize)
  }
})

Multi-Criteria Filtering

Filter Component

<script setup lang="ts">
interface FilterState {
  search: string
  role: string
  status: string
  dateFrom: string
  dateTo: string
}

const emit = defineEmits<{
  filter: [filters: FilterState]
}>()

const filters = ref<FilterState>({
  search: '',
  role: '',
  status: '',
  dateFrom: '',
  dateTo: ''
})

const roles = ['admin', 'user', 'guest']
const statuses = ['active', 'inactive', 'pending']

function applyFilters() {
  const activeFilters = Object.fromEntries(
    Object.entries(filters.value).filter(([_, v]) => v !== '')
  )
  emit('filter', activeFilters as FilterState)
}

function resetFilters() {
  filters.value = {
    search: '',
    role: '',
    status: '',
    dateFrom: '',
    dateTo: ''
  }
  applyFilters()
}
</script>

<template>
  <div class="filters">
    <input
      v-model="filters.search"
      type="text"
      placeholder="Search by name or email..."
      @input="applyFilters"
    />
    
    <select v-model="filters.role" @change="applyFilters">
      <option value="">All Roles</option>
      <option v-for="role in roles" :key="role" :value="role">
        {{ role }}
      </option>
    </select>
    
    <select v-model="filters.status" @change="applyFilters">
      <option value="">All Statuses</option>
      <option v-for="status in statuses" :key="status" :value="status">
        {{ status }}
      </option>
    </select>
    
    <input
      v-model="filters.dateFrom"
      type="date"
      placeholder="From"
      @change="applyFilters"
    />
    
    <input
      v-model="filters.dateTo"
      type="date"
      placeholder="To"
      @change="applyFilters"
    />
    
    <button @click="resetFilters">Reset</button>
  </div>
</template>

Data Table Component

<script setup lang="ts">
interface User {
  id: string
  name: string
  email: string
  role: string
  status: string
  createdAt: string
}

const {
  data: users,
  total,
  loading,
  state,
  totalPages,
  setPage,
  setSort,
  setFilters,
  refresh
} = useDataTable<User>('/api/users')

const selectedRows = ref<Set<string>>(new Set())

function toggleRow(id: string) {
  if (selectedRows.value.has(id)) {
    selectedRows.value.delete(id)
  } else {
    selectedRows.value.add(id)
  }
}

function toggleAll() {
  if (selectedRows.value.size === users.value.length) {
    selectedRows.value.clear()
  } else {
    users.value.forEach(u => selectedRows.value.add(u.id))
  }
}

const allSelected = computed(() => 
  users.value.length > 0 && selectedRows.value.size === users.value.length
)
</script>

<template>
  <div class="table-container">
    <div class="table-header">
      <h2>Users ({{ total }})</h2>
      <button @click="refresh">Refresh</button>
    </div>
    
    <TableFilters @filter="setFilters" />
    
    <div v-if="selectedRows.size > 0" class="bulk-actions">
      <span>{{ selectedRows.size }} selected</span>
      <button @click="bulkDelete">Delete</button>
      <button @click="bulkExport">Export</button>
    </div>
    
    <table>
      <thead>
        <tr>
          <th>
            <input
              type="checkbox"
              :checked="allSelected"
              @change="toggleAll"
            />
          </th>
          <th @click="setSort('name')">
            Name
            <SortIcon :active="state.sortBy === 'name'" :order="state.sortOrder" />
          </th>
          <th @click="setSort('email')">
            Email
            <SortIcon :active="state.sortBy === 'email'" :order="state.sortOrder" />
          </th>
          <th @click="setSort('role')">Role</th>
          <th @click="setSort('status')">Status</th>
          <th @click="setSort('createdAt')">Created</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody v-if="!loading">
        <tr v-for="user in users" :key="user.id">
          <td>
            <input
              type="checkbox"
              :checked="selectedRows.has(user.id)"
              @change="toggleRow(user.id)"
            />
          </td>
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td>{{ user.role }}</td>
          <td>
            <StatusBadge :status="user.status" />
          </td>
          <td>{{ formatDate(user.createdAt) }}</td>
          <td>
            <button @click="editUser(user)">Edit</button>
            <button @click="deleteUser(user.id)">Delete</button>
          </td>
        </tr>
      </tbody>
      <tbody v-else>
        <tr>
          <td colspan="7" class="loading">Loading...</td>
        </tr>
      </tbody>
    </table>
    
    <Pagination
      :current-page="state.page"
      :total-pages="totalPages"
      @change="setPage"
    />
  </div>
</template>

Inline Editing

<script setup lang="ts">
interface EditableUser extends User {
  _editing?: boolean
}

const users = ref<EditableUser[]>([])
const editingId = ref<string | null>(null)
const editForm = ref<Partial<User>>({})

function startEdit(user: User) {
  editingId.value = user.id
  editForm.value = { ...user }
}

function cancelEdit() {
  editingId.value = null
  editForm.value = {}
}

async function saveEdit() {
  if (!editingId.value) return
  
  try {
    await $fetch(`/api/users/${editingId.value}`, {
      method: 'PATCH',
      body: editForm.value
    })
    
    const index = users.value.findIndex(u => u.id === editingId.value)
    if (index !== -1) {
      users.value[index] = { ...users.value[index], ...editForm.value }
    }
    
    cancelEdit()
  } catch (error) {
    console.error('Failed to save:', error)
  }
}
</script>

<template>
  <tr v-for="user in users" :key="user.id">
    <td v-if="editingId === user.id">
      <input v-model="editForm.name" />
    </td>
    <td v-else>{{ user.name }}</td>
    
    <td v-if="editingId === user.id">
      <input v-model="editForm.email" type="email" />
    </td>
    <td v-else>{{ user.email }}</td>
    
    <td>
      <template v-if="editingId === user.id">
        <button @click="saveEdit">Save</button>
        <button @click="cancelEdit">Cancel</button>
      </template>
      <template v-else>
        <button @click="startEdit(user)">Edit</button>
      </template>
    </td>
  </tr>
</template>

CSV Export

// app/composables/useTableExport.ts
export function useTableExport() {
  function exportToCSV<T extends Record<string, any>>(
    data: T[],
    filename: string,
    columns: { key: keyof T; label: string }[]
  ) {
    // Create CSV content
    const headers = columns.map(col => col.label).join(',')
    const rows = data.map(row =>
      columns.map(col => {
        const value = row[col.key]
        // Escape quotes and wrap in quotes if contains comma
        const escaped = String(value).replace(/"/g, '""')
        return escaped.includes(',') ? `"${escaped}"` : escaped
      }).join(',')
    )
    
    const csv = [headers, ...rows].join('\n')
    
    // Download
    const blob = new Blob([csv], { type: 'text/csv' })
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = `${filename}.csv`
    link.click()
    URL.revokeObjectURL(url)
  }
  
  async function exportAllToCSV(endpoint: string, filename: string) {
    const { data } = await $fetch<{ data: any[] }>(
      `${endpoint}?pageSize=10000`
    )
    exportToCSV(data, filename, [
      { key: 'name', label: 'Name' },
      { key: 'email', label: 'Email' },
      { key: 'role', label: 'Role' }
    ])
  }
  
  return {
    exportToCSV,
    exportAllToCSV
  }
}

Virtual Scrolling for Large Datasets

<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'

const allData = ref<User[]>([])

const { list, containerProps, wrapperProps } = useVirtualList(allData, {
  itemHeight: 50,
  overscan: 10
})
</script>

<template>
  <div v-bind="containerProps" class="virtual-container">
    <div v-bind="wrapperProps">
      <div
        v-for="{ data: user, index } in list"
        :key="user.id"
        class="virtual-row"
      >
        <span>{{ user.name }}</span>
        <span>{{ user.email }}</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-container {
  height: 600px;
  overflow-y: auto;
}

.virtual-row {
  height: 50px;
  display: flex;
  align-items: center;
  border-bottom: 1px solid #eee;
}
</style>

Bulk Actions

// app/composables/useBulkActions.ts
export function useBulkActions<T extends { id: string }>(
  endpoint: string
) {
  const processing = ref(false)
  
  async function bulkDelete(ids: string[]) {
    processing.value = true
    
    try {
      await $fetch(`${endpoint}/bulk`, {
        method: 'DELETE',
        body: { ids }
      })
    } finally {
      processing.value = false
    }
  }
  
  async function bulkUpdate(ids: string[], data: Partial<T>) {
    processing.value = true
    
    try {
      await $fetch(`${endpoint}/bulk`, {
        method: 'PATCH',
        body: { ids, data }
      })
    } finally {
      processing.value = false
    }
  }
  
  return {
    processing: readonly(processing),
    bulkDelete,
    bulkUpdate
  }
}

Best Practices

✅ Do's

  1. ✅ Implement server-side pagination for large datasets
  2. ✅ Debounce search inputs to reduce API calls
  3. ✅ Use virtual scrolling for 1000+ rows
  4. ✅ Show loading states during operations
  5. ✅ Validate inline edits before saving
  6. ✅ Provide clear feedback for bulk actions
  7. ✅ Cache table state in URL for bookmarking

❌ Don'ts

  1. ❌ Don't load all data at once for large datasets
  2. ❌ Don't forget to handle errors gracefully
  3. ❌ Don't allow editing without validation
  4. ❌ Don't export without row limits
  5. ❌ Don't forget accessibility (keyboard navigation)

Advanced Features

Column Visibility Toggle

const columns = ref([
  { key: 'name', label: 'Name', visible: true },
  { key: 'email', label: 'Email', visible: true },
  { key: 'role', label: 'Role', visible: true },
  { key: 'status', label: 'Status', visible: false }
])

const visibleColumns = computed(() => 
  columns.value.filter(col => col.visible)
)

Persist Table State

// Auto-save to localStorage
watch(state, (newState) => {
  localStorage.setItem('tableState', JSON.stringify(newState))
}, { deep: true })

// Restore on mount
onMounted(() => {
  const saved = localStorage.getItem('tableState')
  if (saved) {
    Object.assign(state.value, JSON.parse(saved))
  }
})

Advanced data tables are essential for enterprise applications. Implement these patterns to provide powerful, user-friendly data management.

Domain-Driven Design (DDD) Architecture
Learn how to structure complex Nuxt applications using Domain-Driven Design principles for maintainable, scalable business applications.
Multi-step Forms
Create wizard-style forms with state management, progressive validation, and navigation for complex data entry workflows using the NuxtUI Stepper component.
Files
Editor
Initializing WebContainer
Mounting files
Installing dependencies
Starting Nuxt server
Waiting for Nuxt to ready
Terminal