DEV Community

Ethan Walker
Ethan Walker

Posted on

Building Advanced Data Tables with Server-Side Processing in flowbite-svelte and Svelte

flowbite-svelte is a comprehensive UI component library built on Tailwind CSS that provides production-ready Svelte components for building modern web applications. This article covers building advanced data tables with server-side processing, real-time updates, and complex state management. This is part 4 of a series on using flowbite-svelte with Svelte.

This guide walks through creating a production-ready data table system with server-side pagination, sorting, filtering, and real-time data synchronization using flowbite-svelte components and Svelte stores.

Prerequisites

Before starting, ensure you have:

  • A SvelteKit project (SvelteKit 1.0+ or standalone Svelte 4+)
  • Node.js 18+ and npm/pnpm/yarn
  • Tailwind CSS configured in your project
  • Understanding of Svelte stores, reactivity, and async operations
  • Basic knowledge of REST APIs and server-side data processing

Installation

Install flowbite-svelte and required dependencies:

npm install flowbite-svelte flowbite
npm install -D tailwindcss postcss autoprefixer
npm install -D @flowbite-svelte-plugins/datatable
Enter fullscreen mode Exit fullscreen mode

For icon support:

npm install -D flowbite-svelte-icons
Enter fullscreen mode Exit fullscreen mode

Project Setup

Configure Tailwind CSS to include flowbite-svelte:

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    './src/**/*.{html,js,svelte,ts}',
    './node_modules/flowbite-svelte/**/*.{svelte,js,ts}'
  ],
  theme: {
    extend: {},
  },
  plugins: [require('flowbite/plugin')],
  darkMode: 'class',
}
Enter fullscreen mode Exit fullscreen mode

Add Tailwind directives to your main CSS file:

/* src/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Import the CSS in your layout:

<!-- src/routes/+layout.svelte -->
<script>
  import '../app.css';
</script>

<slot />
Enter fullscreen mode Exit fullscreen mode

First Example / Basic Usage

Let's start with a simple server-side data table:

<!-- src/lib/ServerDataTable.svelte -->
<script lang="ts">
  import { 
    TableHead, 
    TableHeadCell, 
    TableBody, 
    TableBodyRow, 
    TableBodyCell,
    Button,
    Spinner
  } from 'flowbite-svelte';

  interface TableData {
    id: string;
    name: string;
    email: string;
    role: string;
    createdAt: string;
  }

  interface TableState {
    data: TableData[];
    loading: boolean;
    page: number;
    pageSize: number;
    total: number;
    sortBy: string;
    sortOrder: 'asc' | 'desc';
  }

  export let apiUrl = '/api/users';
  export let pageSize = 10;

  let state: TableState = $state({
    data: [],
    loading: false,
    page: 1,
    pageSize,
    total: 0,
    sortBy: 'createdAt',
    sortOrder: 'desc'
  });

  async function fetchData() {
    state.loading = true;
    try {
      const params = new URLSearchParams({
        page: state.page.toString(),
        pageSize: state.pageSize.toString(),
        sortBy: state.sortBy,
        sortOrder: state.sortOrder
      });

      const response = await fetch(`${apiUrl}?${params}`);
      const result = await response.json();

      state.data = result.data;
      state.total = result.total;
    } catch (error) {
      console.error('Failed to fetch data:', error);
    } finally {
      state.loading = false;
    }
  }

  function handleSort(column: string) {
    if (state.sortBy === column) {
      state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc';
    } else {
      state.sortBy = column;
      state.sortOrder = 'asc';
    }
    fetchData();
  }

  function handlePageChange(newPage: number) {
    state.page = newPage;
    fetchData();
  }

  $effect(() => {
    fetchData();
  });
</script>

<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
  {#if state.loading}
    <div class="flex justify-center items-center p-8">
      <Spinner size="xl" />
    </div>
  {:else}
    <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
      <TableHead>
        <TableHeadCell>
          <button 
            class="flex items-center"
            on:click={() => handleSort('name')}
          >
            Name
            {#if state.sortBy === 'name'}
              <span class="ml-1">
                {state.sortOrder === 'asc' ? '' : ''}
              </span>
            {/if}
          </button>
        </TableHeadCell>
        <TableHeadCell>
          <button 
            class="flex items-center"
            on:click={() => handleSort('email')}
          >
            Email
            {#if state.sortBy === 'email'}
              <span class="ml-1">
                {state.sortOrder === 'asc' ? '' : ''}
              </span>
            {/if}
          </button>
        </TableHeadCell>
        <TableHeadCell>
          <button 
            class="flex items-center"
            on:click={() => handleSort('role')}
          >
            Role
            {#if state.sortBy === 'role'}
              <span class="ml-1">
                {state.sortOrder === 'asc' ? '' : ''}
              </span>
            {/if}
          </button>
        </TableHeadCell>
        <TableHeadCell>
          <button 
            class="flex items-center"
            on:click={() => handleSort('createdAt')}
          >
            Created
            {#if state.sortBy === 'createdAt'}
              <span class="ml-1">
                {state.sortOrder === 'asc' ? '' : ''}
              </span>
            {/if}
          </button>
        </TableHeadCell>
      </TableHead>
      <TableBody>
        {#each state.data as item (item.id)}
          <TableBodyRow>
            <TableBodyCell class="font-medium text-gray-900 dark:text-white">
              {item.name}
            </TableBodyCell>
            <TableBodyCell>{item.email}</TableBodyCell>
            <TableBodyCell>{item.role}</TableBodyCell>
            <TableBodyCell>
              {new Date(item.createdAt).toLocaleDateString()}
            </TableBodyCell>
          </TableBodyRow>
        {/each}
      </TableBody>
    </table>
  {/if}

  <div class="flex items-center justify-between p-4">
    <div class="text-sm text-gray-700 dark:text-gray-400">
      Showing {(state.page - 1) * state.pageSize + 1} to 
      {Math.min(state.page * state.pageSize, state.total)} of {state.total} entries
    </div>
    <div class="flex gap-2">
      <Button 
        size="xs" 
        disabled={state.page === 1}
        on:click={() => handlePageChange(state.page - 1)}
      >
        Previous
      </Button>
      <Button 
        size="xs" 
        disabled={state.page * state.pageSize >= state.total}
        on:click={() => handlePageChange(state.page + 1)}
      >
        Next
      </Button>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Using the component:

<!-- src/routes/+page.svelte -->
<script>
  import ServerDataTable from '$lib/ServerDataTable.svelte';
</script>

<div class="p-6">
  <h1 class="text-2xl font-bold mb-4">Users Table</h1>
  <ServerDataTable apiUrl="/api/users" pageSize={10} />
</div>
Enter fullscreen mode Exit fullscreen mode

This example demonstrates a basic server-side data table with sorting and pagination. The component fetches data from an API endpoint, handles loading states, and provides interactive sorting controls.

Understanding the Basics

flowbite-svelte provides several approaches for building data tables:

  1. Basic Table Components - TableHead, TableBody, TableBodyRow, etc. for custom implementations
  2. DataTable Plugin - @flowbite-svelte-plugins/datatable for feature-rich tables
  3. Server-Side Processing - Custom implementation with API integration

For advanced use cases with server-side processing, we use the basic components because they provide:

  • Full control over data fetching logic
  • Custom state management
  • Integration with Svelte stores
  • Real-time update capabilities

Key concepts:

  • Reactive State - Using $state for reactive table state
  • Effects - Using $effect to trigger data fetching on state changes
  • Server-Side Pagination - Fetching only the current page from the server
  • Sorting - Sending sort parameters to the server

Practical Example / Building Something Real

Let's create a complete advanced data table system with filtering, real-time updates, and state management:

// src/stores/dataTableStore.ts
import { writable, derived } from 'svelte/store';

export interface TableFilters {
  search?: string;
  role?: string;
  dateFrom?: string;
  dateTo?: string;
}

export interface TableState {
  page: number;
  pageSize: number;
  sortBy: string;
  sortOrder: 'asc' | 'desc';
  filters: TableFilters;
}

function createDataTableStore(initialState: Partial<TableState> = {}) {
  const { subscribe, update, set } = writable<TableState>({
    page: 1,
    pageSize: 10,
    sortBy: 'createdAt',
    sortOrder: 'desc',
    filters: {},
    ...initialState
  });

  return {
    subscribe,
    setPage: (page: number) => update(s => ({ ...s, page })),
    setPageSize: (size: number) => update(s => ({ ...s, pageSize: size, page: 1 })),
    setSort: (sortBy: string, sortOrder: 'asc' | 'desc') => 
      update(s => ({ ...s, sortBy, sortOrder, page: 1 })),
    setFilters: (filters: TableFilters) => 
      update(s => ({ ...s, filters, page: 1 })),
    reset: () => set({
      page: 1,
      pageSize: 10,
      sortBy: 'createdAt',
      sortOrder: 'desc',
      filters: {}
    })
  };
}

export const createTableStore = createDataTableStore;
Enter fullscreen mode Exit fullscreen mode

Create the advanced table component:

<!-- src/lib/AdvancedDataTable.svelte -->
<script lang="ts">
  import { 
    TableHead, 
    TableHeadCell, 
    TableBody, 
    TableBodyRow, 
    TableBodyCell,
    Button,
    Input,
    Select,
    Spinner,
    Badge
  } from 'flowbite-svelte';
  import { createTableStore, type TableFilters } from '$stores/dataTableStore';
  import { onMount } from 'svelte';

  interface TableData {
    id: string;
    name: string;
    email: string;
    role: string;
    status: 'active' | 'inactive';
    createdAt: string;
  }

  interface ApiResponse {
    data: TableData[];
    total: number;
    page: number;
    pageSize: number;
  }

  export let apiUrl = '/api/users';
  export let pageSize = 10;
  export let enableRealtime = false;

  const tableStore = createTableStore({ pageSize });
  let tableState = $state({ data: [] as TableData[], total: 0, loading: false });
  let filters = $state<TableFilters>({});
  let searchDebounceTimer: ReturnType<typeof setTimeout>;

  async function fetchData() {
    tableState.loading = true;
    try {
      const state = $state.snapshot(tableStore);
      const params = new URLSearchParams({
        page: state.page.toString(),
        pageSize: state.pageSize.toString(),
        sortBy: state.sortBy,
        sortOrder: state.sortOrder,
        ...Object.entries(state.filters).reduce((acc, [key, value]) => {
          if (value) acc[key] = value.toString();
          return acc;
        }, {} as Record<string, string>)
      });

      const response = await fetch(`${apiUrl}?${params}`);
      if (!response.ok) throw new Error('Failed to fetch data');

      const result: ApiResponse = await response.json();
      tableState.data = result.data;
      tableState.total = result.total;
    } catch (error) {
      console.error('Failed to fetch data:', error);
    } finally {
      tableState.loading = false;
    }
  }

  function handleSort(column: string) {
    const state = $state.snapshot(tableStore);
    const newOrder = state.sortBy === column && state.sortOrder === 'asc' 
      ? 'desc' 
      : 'asc';
    tableStore.setSort(column, newOrder);
  }

  function handleFilterChange(key: keyof TableFilters, value: string) {
    filters = { ...filters, [key]: value || undefined };

    if (key === 'search') {
      clearTimeout(searchDebounceTimer);
      searchDebounceTimer = setTimeout(() => {
        tableStore.setFilters(filters);
      }, 300);
    } else {
      tableStore.setFilters(filters);
    }
  }

  function handlePageChange(newPage: number) {
    tableStore.setPage(newPage);
  }

  function handlePageSizeChange(size: number) {
    tableStore.setPageSize(size);
  }

  // Subscribe to store changes and refetch data
  $effect(() => {
    const unsubscribe = tableStore.subscribe(() => {
      fetchData();
    });
    return unsubscribe;
  });

  // Real-time updates using polling
  let pollInterval: ReturnType<typeof setInterval>;

  onMount(() => {
    fetchData();

    if (enableRealtime) {
      pollInterval = setInterval(() => {
        fetchData();
      }, 5000); // Poll every 5 seconds
    }

    return () => {
      if (pollInterval) clearInterval(pollInterval);
      clearTimeout(searchDebounceTimer);
    };
  });

  $: currentState = $state.snapshot(tableStore);
  $: totalPages = Math.ceil(tableState.total / currentState.pageSize);
  $: startItem = (currentState.page - 1) * currentState.pageSize + 1;
  $: endItem = Math.min(currentState.page * currentState.pageSize, tableState.total);
</script>

<div class="space-y-4">
  <!-- Filters -->
  <div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
    <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
      <div>
        <Input
          placeholder="Search..."
          bind:value={filters.search}
          on:input={(e) => handleFilterChange('search', e.target.value)}
        />
      </div>
      <div>
        <Select
          bind:value={filters.role}
          on:change={(e) => handleFilterChange('role', e.target.value)}
        >
          <option value="">All Roles</option>
          <option value="admin">Admin</option>
          <option value="user">User</option>
          <option value="moderator">Moderator</option>
        </Select>
      </div>
      <div>
        <Input
          type="date"
          placeholder="From Date"
          bind:value={filters.dateFrom}
          on:input={(e) => handleFilterChange('dateFrom', e.target.value)}
        />
      </div>
      <div>
        <Input
          type="date"
          placeholder="To Date"
          bind:value={filters.dateTo}
          on:input={(e) => handleFilterChange('dateTo', e.target.value)}
        />
      </div>
    </div>
  </div>

  <!-- Table -->
  <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
    {#if tableState.loading && tableState.data.length === 0}
      <div class="flex justify-center items-center p-8">
        <Spinner size="xl" />
      </div>
    {:else}
      <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
        <TableHead>
          <TableHeadCell>
            <button 
              class="flex items-center hover:text-gray-900 dark:hover:text-white"
              on:click={() => handleSort('name')}
            >
              Name
              {#if currentState.sortBy === 'name'}
                <span class="ml-1 text-gray-900 dark:text-white">
                  {currentState.sortOrder === 'asc' ? '' : ''}
                </span>
              {/if}
            </button>
          </TableHeadCell>
          <TableHeadCell>
            <button 
              class="flex items-center hover:text-gray-900 dark:hover:text-white"
              on:click={() => handleSort('email')}
            >
              Email
              {#if currentState.sortBy === 'email'}
                <span class="ml-1 text-gray-900 dark:text-white">
                  {currentState.sortOrder === 'asc' ? '' : ''}
                </span>
              {/if}
            </button>
          </TableHeadCell>
          <TableHeadCell>
            <button 
              class="flex items-center hover:text-gray-900 dark:hover:text-white"
              on:click={() => handleSort('role')}
            >
              Role
              {#if currentState.sortBy === 'role'}
                <span class="ml-1 text-gray-900 dark:text-white">
                  {currentState.sortOrder === 'asc' ? '' : ''}
                </span>
              {/if}
            </button>
          </TableHeadCell>
          <TableHeadCell>Status</TableHeadCell>
          <TableHeadCell>
            <button 
              class="flex items-center hover:text-gray-900 dark:hover:text-white"
              on:click={() => handleSort('createdAt')}
            >
              Created
              {#if currentState.sortBy === 'createdAt'}
                <span class="ml-1 text-gray-900 dark:text-white">
                  {currentState.sortOrder === 'asc' ? '' : ''}
                </span>
              {/if}
            </button>
          </TableHeadCell>
        </TableHead>
        <TableBody>
          {#each tableState.data as item (item.id)}
            <TableBodyRow class="bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-600">
              <TableBodyCell class="font-medium text-gray-900 dark:text-white">
                {item.name}
              </TableBodyCell>
              <TableBodyCell>{item.email}</TableBodyCell>
              <TableBodyCell>
                <Badge color="info">{item.role}</Badge>
              </TableBodyCell>
              <TableBodyCell>
                <Badge color={item.status === 'active' ? 'success' : 'warning'}>
                  {item.status}
                </Badge>
              </TableBodyCell>
              <TableBodyCell>
                {new Date(item.createdAt).toLocaleDateString()}
              </TableBodyCell>
            </TableBodyRow>
          {/each}
        </TableBody>
      </table>
    {/if}
  </div>

  <!-- Pagination -->
  <div class="flex flex-col sm:flex-row items-center justify-between gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg shadow">
    <div class="flex items-center gap-4">
      <span class="text-sm text-gray-700 dark:text-gray-400">
        Show
      </span>
      <Select
        value={currentState.pageSize.toString()}
        on:change={(e) => handlePageSizeChange(Number(e.target.value))}
        class="w-20"
      >
        <option value="10">10</option>
        <option value="25">25</option>
        <option value="50">50</option>
        <option value="100">100</option>
      </Select>
      <span class="text-sm text-gray-700 dark:text-gray-400">
        entries
      </span>
    </div>

    <div class="text-sm text-gray-700 dark:text-gray-400">
      Showing <span class="font-semibold">{startItem}</span> to 
      <span class="font-semibold">{endItem}</span> of 
      <span class="font-semibold">{tableState.total}</span> entries
    </div>

    <div class="flex gap-2">
      <Button 
        size="xs" 
        color="light"
        disabled={currentState.page === 1}
        on:click={() => handlePageChange(currentState.page - 1)}
      >
        Previous
      </Button>

      {#each Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
        const page = Math.max(1, Math.min(totalPages - 4, currentState.page - 2)) + i;
        return page <= totalPages ? page : null;
      }).filter(Boolean) as page}
        <Button
          size="xs"
          color={page === currentState.page ? 'blue' : 'light'}
          on:click={() => handlePageChange(page)}
        >
          {page}
        </Button>
      {/each}

      <Button 
        size="xs" 
        color="light"
        disabled={currentState.page >= totalPages}
        on:click={() => handlePageChange(currentState.page + 1)}
      >
        Next
      </Button>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Example API route for SvelteKit:

// src/routes/api/users/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url }) => {
  const page = parseInt(url.searchParams.get('page') || '1');
  const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
  const sortBy = url.searchParams.get('sortBy') || 'createdAt';
  const sortOrder = url.searchParams.get('sortOrder') || 'desc';
  const search = url.searchParams.get('search') || '';
  const role = url.searchParams.get('role') || '';

  // Simulate database query
  // In production, replace with actual database query
  let data = [
    { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin', status: 'active', createdAt: '2024-01-15' },
    { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'user', status: 'active', createdAt: '2024-01-20' },
    // ... more data
  ];

  // Apply filters
  if (search) {
    data = data.filter(item => 
      item.name.toLowerCase().includes(search.toLowerCase()) ||
      item.email.toLowerCase().includes(search.toLowerCase())
    );
  }

  if (role) {
    data = data.filter(item => item.role === role);
  }

  // Apply sorting
  data.sort((a, b) => {
    const aVal = a[sortBy as keyof typeof a];
    const bVal = b[sortBy as keyof typeof b];
    const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
    return sortOrder === 'asc' ? comparison : -comparison;
  });

  // Apply pagination
  const total = data.length;
  const start = (page - 1) * pageSize;
  const end = start + pageSize;
  const paginatedData = data.slice(start, end);

  return json({
    data: paginatedData,
    total,
    page,
    pageSize
  });
};
Enter fullscreen mode Exit fullscreen mode

Using the advanced table:

<!-- src/routes/users/+page.svelte -->
<script>
  import AdvancedDataTable from '$lib/AdvancedDataTable.svelte';
</script>

<div class="p-6">
  <h1 class="text-2xl font-bold mb-4">Users Management</h1>
  <AdvancedDataTable 
    apiUrl="/api/users" 
    pageSize={10}
    enableRealtime={true}
  />
</div>
Enter fullscreen mode Exit fullscreen mode

This example demonstrates a complete advanced data table system with:

  • Server-side pagination, sorting, and filtering
  • Debounced search input
  • Real-time data updates via polling
  • Centralized state management with Svelte stores
  • Loading states and error handling
  • Responsive design with Tailwind CSS

Common Issues / Troubleshooting

  1. Table not updating after state changes

    • Ensure you're subscribing to the store properly using $effect or reactive statements
    • Check that the store's set methods trigger reactivity correctly
    • Verify that fetchData() is called when state changes
  2. Performance issues with large datasets

    • Implement proper debouncing for search inputs (300ms recommended)
    • Use server-side pagination instead of loading all data
    • Consider virtual scrolling for very large datasets
    • Limit real-time polling frequency (5+ seconds)
  3. Sorting not working correctly

    • Verify that your API endpoint handles sort parameters correctly
    • Check that sort state is properly reset when changing sort column
    • Ensure sort order toggles correctly (asc ↔ desc)
  4. Filters not applying

    • Check that filter values are properly sent to the API
    • Verify URLSearchParams construction includes all filter keys
    • Ensure the API endpoint processes all filter parameters

Next Steps

  • Add column visibility toggles: Allow users to show/hide columns
  • Implement export functionality: Add CSV/Excel export for table data
  • Add bulk actions: Implement row selection and bulk operations
  • Integrate WebSocket: Replace polling with WebSocket for true real-time updates
  • Add advanced filtering: Implement date range pickers and multi-select filters
  • Optimize with caching: Add response caching to reduce server load

Summary

In this article, we created an advanced data table system using flowbite-svelte with server-side processing, real-time updates, and comprehensive state management. The system includes pagination, sorting, filtering, and real-time synchronization, providing a production-ready solution for managing large datasets in Svelte applications.

Top comments (0)