Summit Themes
Blog

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

If you need an interactive data table — sortable columns, page-by-page navigation, maybe a search filter — your first instinct might be to reach for a React component library or a heavyweight data-grid package. But for most local-business dashboards, admin panels, and marketing sites, that is overkill. Alpine.js gives you reactive, stateful behavior directly in your HTML with a script tag and no build step at all.

This tutorial walks through building a real, working data table from scratch using Alpine.js v3 (currently v3.15.12). You will get sortable column headers, next/previous pagination, and a row-count selector — all in plain HTML with Alpine directives. The patterns here drop straight into any Astro page, a Blade template, or a plain HTML file.

What you are building

A table of customer records with these features:

  • Click any column header to sort ascending; click again to sort descending.
  • A sort indicator arrow shows the current sort direction.
  • Rows are paginated (5, 10, or 25 per page).
  • A "Showing X–Y of Z" label updates automatically.
  • No dependencies beyond Alpine itself.

Step 1: Add Alpine.js

Drop this script tag into your <head> or just before </body>. The defer attribute is recommended when placing it in the head so Alpine initializes after the DOM is ready.

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

The 3.x.x range tag always resolves to the latest stable v3 release. If you need a pinned version for production, replace it with the specific version number — for example, [email protected].

Step 2: Define the component data

Alpine's x-data directive turns any element into a reactive component. All of the table's state lives in a single object: the raw data, the sort key and direction, the current page, and the page size.

<div x-data="dataTable()">
  <!-- table markup goes here -->
</div>

<script>
function dataTable() {
  return {
    // Raw data — replace with your real rows or a fetch() call
    rows: [
      { name: "Alice Morrow",   city: "Denver",      role: "Manager",   joined: "2022-03-14" },
      { name: "Ben Okafor",     city: "Austin",      role: "Developer", joined: "2021-07-08" },
      { name: "Clara Nguyen",   city: "Phoenix",     role: "Designer",  joined: "2023-01-22" },
      { name: "Diego Reyes",    city: "Chicago",     role: "Developer", joined: "2020-11-30" },
      { name: "Eva Lindström",  city: "Seattle",     role: "Manager",   joined: "2024-02-05" },
      { name: "Frank Dupont",   city: "Miami",       role: "Designer",  joined: "2022-09-17" },
      { name: "Grace Kim",      city: "Denver",      role: "Developer", joined: "2021-04-12" },
      { name: "Hiro Tanaka",    city: "Portland",    role: "Manager",   joined: "2023-06-28" },
      { name: "Isla Brennan",   city: "Austin",      role: "Designer",  joined: "2020-08-03" },
      { name: "Jake Ferreira",  city: "Phoenix",     role: "Developer", joined: "2024-05-19" },
      { name: "Kaia Patel",     city: "Seattle",     role: "Manager",   joined: "2022-12-01" },
      { name: "Luca Moretti",   city: "Chicago",     role: "Developer", joined: "2021-10-15" },
    ],

    // Sort state
    sortKey: "name",
    sortDir: "asc",

    // Pagination state
    page: 1,
    pageSize: 5,

    // Derived: rows after sorting
    get sorted() {
      return [...this.rows].sort((a, b) => {
        const valA = a[this.sortKey].toLowerCase();
        const valB = b[this.sortKey].toLowerCase();
        if (valA < valB) return this.sortDir === "asc" ? -1 : 1;
        if (valA > valB) return this.sortDir === "asc" ?  1 : -1;
        return 0;
      });
    },

    // Derived: total pages
    get totalPages() {
      return Math.ceil(this.rows.length / this.pageSize);
    },

    // Derived: the visible slice for the current page
    get paginated() {
      const start = (this.page - 1) * this.pageSize;
      return this.sorted.slice(start, start + this.pageSize);
    },

    // Derived: human-readable range label
    get rangeLabel() {
      const start = (this.page - 1) * this.pageSize + 1;
      const end   = Math.min(this.page * this.pageSize, this.rows.length);
      return `Showing ${start}–${end} of ${this.rows.length}`;
    },

    // Sort by a column; toggle direction if the same column is clicked
    sortBy(key) {
      if (this.sortKey === key) {
        this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
      } else {
        this.sortKey = key;
        this.sortDir = "asc";
      }
      this.page = 1; // reset to first page on re-sort
    },

    // Change page size and reset pagination
    changePageSize(n) {
      this.pageSize = Number(n);
      this.page = 1;
    },
  };
}
</script>

A few notes on this structure. The get sorted(), get paginated(), and get rangeLabel() properties are JavaScript getters — they re-compute automatically whenever Alpine re-evaluates the component, which happens any time reactive data changes. This means you never manually update derived state; it just follows. The [...this.rows].sort() spread creates a new array so you never mutate the original data.

Step 3: Build the table markup

Now wire up the HTML. The key directives you will use are x-for (loop over paginated rows), x-text (render values), @click (sort headers and pagination buttons), and x-show (disable buttons at boundaries).

<div x-data="dataTable()">

  <!-- Page-size selector -->
  <div>
    <label>
      Rows per page:
      <select @change="changePageSize($event.target.value)">
        <option value="5">5</option>
        <option value="10">10</option>
        <option value="25">25</option>
      </select>
    </label>
  </div>

  <!-- Table -->
  <table>
    <thead>
      <tr>
        <template x-for="col in ['name', 'city', 'role', 'joined']" :key="col">
          <th @click="sortBy(col)" style="cursor:pointer">
            <span x-text="col.charAt(0).toUpperCase() + col.slice(1)"></span>
            <span x-show="sortKey === col"
                  x-text="sortDir === 'asc' ? ' ▲' : ' ▼'"></span>
          </th>
        </template>
      </tr>
    </thead>
    <tbody>
      <template x-for="(row, index) in paginated" :key="index">
        <tr>
          <td x-text="row.name"></td>
          <td x-text="row.city"></td>
          <td x-text="row.role"></td>
          <td x-text="row.joined"></td>
        </tr>
      </template>
    </tbody>
  </table>

  <!-- Pagination controls -->
  <div>
    <span x-text="rangeLabel"></span>

    <button @click="page--" :disabled="page === 1">Previous</button>
    <span x-text="`Page ${page} of ${totalPages}`"></span>
    <button @click="page++" :disabled="page === totalPages">Next</button>
  </div>

</div>

How sorting works

When a header cell is clicked, sortBy(col) is called. It checks whether the clicked column is already the active sort key. If it is, the direction flips between "asc" and "desc". If it is a different column, the key updates and direction resets to ascending. The page also resets to 1 so users do not land on a now-invalid page after re-sorting.

The sort arrow indicator uses two Alpine directives together:

  • x-show="sortKey === col" — only renders the arrow for the currently sorted column.
  • x-text="sortDir === 'asc' ? ' ▲' : ' ▼'" — picks the correct arrow character based on direction.

The actual sort logic in the sorted getter compares string values using .toLowerCase() so that "Apple" and "apple" sort consistently. For numeric or date columns, you would compare values directly rather than lowercased strings — adjust the comparator for the data type of each column.

How pagination works

Pagination is entirely arithmetic. The paginated getter slices the already-sorted array:

// page 1, pageSize 5: slice(0, 5)
// page 2, pageSize 5: slice(5, 10)
const start = (this.page - 1) * this.pageSize;
return this.sorted.slice(start, start + this.pageSize);

The Previous and Next buttons use Alpine's :disabled binding (shorthand for x-bind:disabled) to disable themselves at the boundaries — page === 1 for Previous and page === totalPages for Next. No extra click-handler logic required; Alpine keeps the state consistent.

Step 4: Add styling

The markup above has no classes. If your project uses Tailwind CSS v4, you can style this with utility classes directly on the elements. Here is the table head cell as an example — note the Tailwind v4 approach uses no configuration file; all theme values are defined in CSS via @theme.

<th
  @click="sortBy(col)"
  class="cursor-pointer select-none px-4 py-3 text-left text-sm font-semibold
         text-gray-700 bg-gray-50 border-b border-gray-200 hover:bg-gray-100"
>

For the table body rows, alternating row colors help readability. Because x-for gives you the index variable, you can conditionally apply a class:

<template x-for="(row, index) in paginated" :key="index">
  <tr :class="index % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
    ...
  </tr>
</template>

Loading data from an API

Right now the rows are hardcoded. To fetch real data, add an init() method to your component object. Alpine calls init() automatically when the component mounts.

function dataTable() {
  return {
    rows: [],
    // ... rest of state

    async init() {
      const response = await fetch("/api/customers");
      this.rows = await response.json();
    },

    // ... getters and methods
  };
}

Because rows is declared as an empty array first, the table renders immediately (empty) and fills in as soon as the fetch resolves. You can add a simple loading state by including a loading: true property that you set to false after the fetch completes, then use x-show="!loading" on the table and x-show="loading" on a skeleton or spinner.

Adding a search filter

A search input is a small addition. Add a search: "" property to the data object, bind it to an input with x-model, and filter before sorting:

search: "",

get filtered() {
  const q = this.search.toLowerCase();
  if (!q) return this.rows;
  return this.rows.filter(row =>
    Object.values(row).some(val => val.toLowerCase().includes(q))
  );
},

get sorted() {
  return [...this.filtered].sort(/* same comparator */);
},
<input
  x-model="search"
  @input="page = 1"
  type="search"
  placeholder="Search..."
/>

The @input="page = 1" resets to the first page whenever the query changes, so users are not stuck looking at an empty page 3 after narrowing results.

Putting it all together

The full pattern is about 60 lines of JavaScript and 30 lines of HTML. No npm install, no bundler, no framework setup. The entire component is defined in one x-data function that you can extract to a separate .js file and reference by name — which is how you would organize it in a real Astro project where you keep JavaScript in src/scripts/ and import it with a <script> tag or Astro's is:inline.

Alpine's reactive getter pattern is worth remembering beyond this specific use case. Any time you have derived state — filtered lists, total counts, computed labels — a JavaScript getter keeps it automatically in sync with zero extra code. Combine that with x-for on a <template> element and Alpine's attribute binding, and you can build surprisingly capable interfaces without touching a component framework.