How to build a data table with sorting and pagination using Alpine.js

Create a fully functional data table with column sorting, pagination controls, and responsive design using Alpine.js and Tailwind CSS

Published on October 3, 2025 by Michael Andreuzza

Let’s build a data table with sorting and pagination

Data tables are essential for displaying large datasets in a manageable way. This tutorial shows you how to build a fully functional table with sorting, pagination, and clean styling using Alpine.js and Tailwind CSS. No backend required—everything runs in the browser.

Heads up: This tutorial assumes you have Tailwind CSS and Alpine.js already set up in your project. The table will work anywhere you drop it.

1. Understanding the component structure

Before diving into the code, let’s understand what we’re building:

  • Sortable columns: Click column headers to sort ascending or descending
  • Pagination: Navigate through data in chunks (5 rows per page)
  • Responsive design: Horizontal scroll for smaller screens
  • Status indicators: Color-coded status column
  • Formatted currency: Proper currency formatting with Intl API

2. Setting up the Alpine.js data structure

The component starts with an x-data directive containing all our state and logic. Let’s break down each piece:

<div
  x-data="{ 
    sortKey:'name', 
    sortDir:'asc', 
    page:1, 
    size:5,
    rows:[
      { name:'Alex Thompson', email:'a.tompson@company.com', location:'San Francisco, US', status:'Inactive', balance:1750 },
      { name:'James Wilson', email:'j.wilson@company.com', location:'London, UK', status:'Inactive', balance:650 },
      { name:'Lars Nielsen', email:'l.nielsen@company.com', location:'Stockholm, SE', status:'Active', balance:1000 },
      { name:'Maria Garcia', email:'m.garcia@company.com', location:'Madrid, Spain', status:'Active', balance:0 },
      { name:'Sarah Chen', email:'sarah.c@company.com', location:'Singapore', status:'Active', balance:600 },
      { name:'David Kim', email:'d.kim@company.com', location:'Seoul, KR', status:'Active', balance:1000 },
      { name:'Emma Laurent', email:'e.laurent@company.com', location:'Berlin, DE', status:'Active', balance:1200 },
    ],
  }"
  class="mx-auto flow-root max-w-7xl"
>
  <!-- Table markup goes here -->
</div>

State properties explained

  • sortKey: The current column being sorted ('name', 'email', or 'balance')
  • sortDir: Sort direction, either 'asc' (ascending) or 'desc' (descending)
  • page: Current page number, starts at 1
  • size: Number of rows per page
  • rows: Your data array—each object represents one table row

3. Adding the sorting logic

Next, we need methods to handle sorting. Add these inside your x-data object:

setSort(k) { 
  if(this.sortKey === k) {
    // Toggle direction if clicking the same column
    this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
  } else {
    // New column: reset to ascending
    this.sortKey = k;
    this.sortDir = 'asc';
  }
  // Reset to first page when sorting changes
  this.page = 1;
}

What it does:

  • If you click the same column header twice, it flips between ascending and descending
  • If you click a different column, it starts fresh with ascending order
  • Always resets to page 1 so you don’t end up on an empty page

4. Implementing the sorted() method

This method returns a sorted copy of your data:

sorted() { 
  const a = [...this.rows];  // Create a copy to avoid mutating original
  const k = this.sortKey;
  const dir = this.sortDir === 'asc' ? 1 : -1;
  
  return a.sort((x, y) => (x[k] > y[k] ? 1 : -1) * dir);
}

How it works:

  • [...this.rows] creates a shallow copy so we don’t modify the original array
  • The sort compares values at the current sortKey
  • Multiplying by dir flips the order for descending sorts

5. Adding pagination methods

These helper methods handle page calculations:

total() { 
  return this.sorted().length; 
},

pages() { 
  return Math.max(1, Math.ceil(this.total() / this.size)); 
},

pageRows() { 
  const s = (this.page - 1) * this.size;
  return this.sorted().slice(s, s + this.size);
}

Breaking it down:

  • total(): Total number of rows after filtering/sorting
  • pages(): Total number of pages (Math.max(1, ...) ensures at least 1 page)
  • pageRows(): Returns only the rows for the current page using slice()

6. Building the table markup

Now let’s create the actual table. Start with the wrapper and table element:

<div class="overflow-x-auto">
  <div class="flex flex-col justify-center">
    <table class="min-w-[900px] text-sm bg-white">
      <!-- Table head and body go here -->
    </table>
  </div>
</div>

The overflow-x-auto wrapper allows horizontal scrolling on small screens, and min-w-[900px] ensures the table doesn’t get cramped.

7. Creating sortable column headers

Let’s build the table header with sortable columns:

<thead class="border-b bg-zinc-100 border-zinc-200">
  <tr>
    <th class="px-3 py-2 font-medium text-left text-zinc-700">
      <button
        @click="setSort('name')"
        class="flex items-center hover:underline gap-1"
      >
        Name
        <template x-if="sortKey==='name'">
          <span>
            <template x-if="sortDir==='asc'">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="icon icon-tabler-chevron-up size-4"
              >
                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                <path d="M6 15l6 -6l6 6"></path>
              </svg>
            </template>
            <template x-if="sortDir==='desc'">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="icon icon-tabler-chevron-down size-4"
              >
                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                <path d="M6 9l6 6l6 -6"></path>
              </svg>
            </template>
          </span>
        </template>
      </button>
    </th>
    
    <th class="px-3 py-2 font-medium text-left text-zinc-700">
      <button @click="setSort('email')" class="hover:underline">
        Email
        <span x-text="sortKey==='email'?(sortDir==='asc'?'▲':'▼'):''"></span>
      </button>
    </th>
    
    <th class="px-3 py-2 font-medium text-left text-zinc-700">
      Location
    </th>
    
    <th class="px-3 py-2 font-medium text-left text-zinc-700">
      Status
    </th>
    
    <th class="px-3 py-2 font-medium text-right text-zinc-700">
      <button @click="setSort('balance')" class="hover:underline">
        Balance
        <span x-text="sortKey==='balance'?(sortDir==='asc'?'▲':'▼'):''"></span>
      </button>
    </th>
  </tr>
</thead>

Key points:

  • The Name column uses SVG icons for sort indicators
  • Email and Balance columns use text arrows ( / ) for simplicity
  • Location and Status columns aren’t sortable (no button)
  • @click="setSort('name')" triggers sorting when clicked

8. Rendering table rows with Alpine

Use x-for to loop through the paginated data:

<tbody>
  <template x-for="r in pageRows()" :key="r.email">
    <tr class="even:bg-zinc-50">
      <td class="px-3 py-2 font-medium text-zinc-700" x-text="r.name"></td>
      <td class="px-3 py-2 text-zinc-500" x-text="r.email"></td>
      <td class="px-3 py-2 text-zinc-500" x-text="r.location"></td>
      <td
        class="px-3 py-2"
        :class="r.status==='Active' ? 'text-emerald-600' : r.status==='Inactive' ? 'text-red-600' : 'text-yellow-600'"
        x-text="r.status"
      ></td>
      <td
        class="px-3 py-2 font-mono font-medium text-right tabular-nums text-zinc-700"
        x-text="Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(r.balance)"
      ></td>
    </tr>
  </template>
  
  <tr x-show="pageRows().length===0">
    <td class="px-3 py-6 text-center text-zinc-500" colspan="5">
      No results
    </td>
  </tr>
</tbody>

What’s happening:

  • x-for="r in pageRows()" loops through the current page’s rows
  • :key="r.email" helps Alpine track rows efficiently
  • even:bg-zinc-50 adds zebra striping automatically
  • Status column uses :class to conditionally apply colors
  • Balance uses Intl.NumberFormat for proper currency formatting
  • Empty state row shows when no results match

9. Building the pagination controls

Add pagination at the bottom of your table:

<div class="flex items-center justify-between p-3 border-t border-zinc-200">
  <div class="font-mono text-sm text-zinc-900">
    <span x-text="(page-1)*size + 1"></span> –
    <span x-text="Math.min(page*size,total())"></span> of
    <span x-text="total()"></span>
  </div>
  
  <div class="flex items-center gap-1">
    <button
      class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
      :disabled="page<=1"
      @click="page=1"
    >
      First
    </button>
    
    <button
      class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
      :disabled="page<=1"
      @click="page=page-1"
    >
      Prev
    </button>
    
    <button
      class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
      :disabled="page>=pages()"
      @click="page=page+1"
    >
      Next
    </button>
    
    <button
      class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
      :disabled="page>=pages()"
      @click="page=pages()"
    >
      Last
    </button>
  </div>
</div>

Pagination features:

  • Shows current range (e.g., “1 – 5 of 7”)
  • Four navigation buttons: First, Prev, Next, Last
  • Buttons disable when at boundaries (:disabled attribute)
  • Simple click handlers update the page variable

10. Complete working example

Here’s the full component assembled:

<div
  x-data="{ 
    sortKey:'name', 
    sortDir:'asc', 
    page:1, 
    size:5,
    rows:[
      { name:'Alex Thompson', email:'a.tompson@company.com', location:'San Francisco, US', status:'Inactive', balance:1750 },
      { name:'James Wilson', email:'j.wilson@company.com', location:'London, UK', status:'Inactive', balance:650 },
      { name:'Lars Nielsen', email:'l.nielsen@company.com', location:'Stockholm, SE', status:'Active', balance:1000 },
      { name:'Maria Garcia', email:'m.garcia@company.com', location:'Madrid, Spain', status:'Active', balance:0 },
      { name:'Sarah Chen', email:'sarah.c@company.com', location:'Singapore', status:'Active', balance:600 },
      { name:'David Kim', email:'d.kim@company.com', location:'Seoul, KR', status:'Active', balance:1000 },
      { name:'Emma Laurent', email:'e.laurent@company.com', location:'Berlin, DE', status:'Active', balance:1200 },
    ],
    setSort(k){ 
      if(this.sortKey===k) {
        this.sortDir=this.sortDir==='asc'?'desc':'asc';
      } else { 
        this.sortKey=k; 
        this.sortDir='asc'; 
      } 
      this.page=1; 
    },
    sorted(){ 
      const a=[...this.rows]; 
      const k=this.sortKey; 
      const dir=this.sortDir==='asc'?1:-1; 
      return a.sort((x,y)=>(x[k]>y[k]?1:-1)*dir); 
    },
    total(){ 
      return this.sorted().length; 
    },
    pages(){ 
      return Math.max(1, Math.ceil(this.total()/this.size)); 
    },
    pageRows(){ 
      const s=(this.page-1)*this.size; 
      return this.sorted().slice(s,s+this.size); 
    }
  }"
  class="mx-auto flow-root max-w-7xl"
>
  <div class="overflow-x-auto">
    <div class="flex flex-col justify-center">
      <table class="min-w-[900px] text-sm bg-white">
        <thead class="border-b bg-zinc-100 border-zinc-200">
          <tr>
            <th class="px-3 py-2 font-medium text-left text-zinc-700">
              <button
                @click="setSort('name')"
                class="flex items-center hover:underline gap-1"
              >
                Name
                <template x-if="sortKey==='name'">
                  <span>
                    <template x-if="sortDir==='asc'">
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        stroke-width="2"
                        stroke-linecap="round"
                        stroke-linejoin="round"
                        class="icon icon-tabler-chevron-up size-4"
                      >
                        <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                        <path d="M6 15l6 -6l6 6"></path>
                      </svg>
                    </template>
                    <template x-if="sortDir==='desc'">
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        stroke-width="2"
                        stroke-linecap="round"
                        stroke-linejoin="round"
                        class="icon icon-tabler-chevron-down size-4"
                      >
                        <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                        <path d="M6 9l6 6l6 -6"></path>
                      </svg>
                    </template>
                  </span>
                </template>
              </button>
            </th>
            <th class="px-3 py-2 font-medium text-left text-zinc-700">
              <button @click="setSort('email')" class="hover:underline">
                Email
                <span x-text="sortKey==='email'?(sortDir==='asc'?'▲':'▼'):''"></span>
              </button>
            </th>
            <th class="px-3 py-2 font-medium text-left text-zinc-700">
              Location
            </th>
            <th class="px-3 py-2 font-medium text-left text-zinc-700">
              Status
            </th>
            <th class="px-3 py-2 font-medium text-right text-zinc-700">
              <button @click="setSort('balance')" class="hover:underline">
                Balance
                <span x-text="sortKey==='balance'?(sortDir==='asc'?'▲':'▼'):''"></span>
              </button>
            </th>
          </tr>
        </thead>
        <tbody>
          <template x-for="r in pageRows()" :key="r.email">
            <tr class="even:bg-zinc-50">
              <td class="px-3 py-2 font-medium text-zinc-700" x-text="r.name"></td>
              <td class="px-3 py-2 text-zinc-500" x-text="r.email"></td>
              <td class="px-3 py-2 text-zinc-500" x-text="r.location"></td>
              <td
                class="px-3 py-2"
                :class="r.status==='Active' ? 'text-emerald-600' : r.status==='Inactive' ? 'text-red-600' : 'text-yellow-600'"
                x-text="r.status"
              ></td>
              <td
                class="px-3 py-2 font-mono font-medium text-right tabular-nums text-zinc-700"
                x-text="Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(r.balance)"
              ></td>
            </tr>
          </template>
          <tr x-show="pageRows().length===0">
            <td class="px-3 py-6 text-center text-zinc-500" colspan="5">
              No results
            </td>
          </tr>
        </tbody>
      </table>
      <div class="flex items-center justify-between p-3 border-t border-zinc-200">
        <div class="font-mono text-sm text-zinc-900">
          <span x-text="(page-1)*size + 1"></span> –
          <span x-text="Math.min(page*size,total())"></span> of
          <span x-text="total()"></span>
        </div>
        <div class="flex items-center gap-1">
          <button
            class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
            :disabled="page<=1"
            @click="page=1"
          >
            First
          </button>
          <button
            class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
            :disabled="page<=1"
            @click="page=page-1"
          >
            Prev
          </button>
          <button
            class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
            :disabled="page>=pages()"
            @click="page=page+1"
          >
            Next
          </button>
          <button
            class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
            :disabled="page>=pages()"
            @click="page=pages()"
          >
            Last
          </button>
        </div>
      </div>
    </div>
  </div>
</div>

11. Customization ideas

Now that you have a working table, here are some ways to extend it:

Add search/filter functionality

query: '',
filtered() {
  const q = this.query.toLowerCase();
  return this.rows.filter(r => 
    r.name.toLowerCase().includes(q) || 
    r.email.toLowerCase().includes(q)
  );
},
// Update sorted() to use filtered() instead of this.rows
sorted() {
  const a = [...this.filtered()];
  // rest of the code...
}

Add a search input

<div class="p-3 border-b border-zinc-200">
  <input
    type="text"
    x-model="query"
    @input="page=1"
    placeholder="Search by name or email..."
    class="w-full px-3 py-2 border rounded-md border-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-500"
  />
</div>

Make rows per page configurable

size: 10,  // Change default
// Add a select dropdown
<select x-model.number="size" @change="page=1" class="px-2 py-1 border rounded border-zinc-200">
  <option value="5">5 per page</option>
  <option value="10">10 per page</option>
  <option value="25">25 per page</option>
  <option value="50">50 per page</option>
</select>

Add row selection

selected: new Set(),
toggleRow(id) {
  if(this.selected.has(id)) {
    this.selected.delete(id);
  } else {
    this.selected.add(id);
  }
}

12. Performance considerations

For larger datasets (1000+ rows), consider these optimizations:

  1. Virtual scrolling: Only render visible rows
  2. Server-side pagination: Fetch data page by page from an API
  3. Debounced search: Wait for typing to stop before filtering
  4. Memoization: Cache sorted results if data doesn’t change often

Wrapping up

You now have a production-ready data table with sorting and pagination, built entirely with Alpine.js and Tailwind CSS. The component is:

  • Lightweight: No heavy dependencies or frameworks
  • Accessible: Semantic HTML with proper button elements
  • Responsive: Works on all screen sizes with horizontal scroll
  • Extensible: Easy to add filters, search, and more features

The key concepts you learned:

  • Using Alpine.js x-data for component state
  • Creating helper methods for sorting and pagination
  • Conditionally rendering with x-if and x-show
  • Looping with x-for to render dynamic rows
  • Binding classes and attributes with :class and :disabled

Copy the code, swap in your own data, and customize the styles to match your design system!

/Michael Andreuzza

Did you like this post? Please share it with your friends!