DEV Community

Tuğrul Yıldırım
Tuğrul Yıldırım

Posted on

Server-Driven Data Tables with Laravel, Inertia and React: Filters, Sorting and Pagination in the URL

Every business app eventually grows a table that people live in: products, invoices, suppliers, open orders. The first version is easy. Load everything, render a <table>, maybe add a client-side filter. Then the table hits 40,000 rows, someone asks for "sort by stock, only active items, page 3", and a colleague wants to send that exact view to a manager as a link.

This post shows the pattern I use for server-driven data tables with Laravel, Inertia and React: the server does the filtering, sorting and pagination, and the browser URL is the single source of truth for the table's state.

The problem with client-side tables

Client-side tables break down in three predictable ways:

  • Data size. Shipping every row to the browser works until it doesn't. Memory use, slow first paint and huge JSON payloads all arrive at once.
  • Unshareable state. If the filters live only in React state, a refresh or a copied link throws them away.
  • Two sources of truth. The server knows the real data; the client holds a filtered copy. Totals, counts and "page 4 of 9" drift apart.

The fix is to keep the query on the server and treat the URL (/products?status=active&sort=price&direction=desc&page=2) as the table's state. Inertia makes this pleasant because a GET visit updates the URL and the page props in one step, without you writing an API layer.

Step 1: A controller that only trusts a whitelist

The controller validates every query parameter. This matters most for sorting: a sort column that goes straight from the query string into orderBy() is an injection risk, and even when it isn't, it lets users sort by unindexed columns you never meant to expose.

// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;

class ProductController extends Controller
{
    private const SORTABLE = ['name', 'sku', 'price', 'stock', 'updated_at'];

    public function index(Request $request): Response
    {
        $filters = $request->validate([
            'search' => ['nullable', 'string', 'max:100'],
            'status' => ['nullable', Rule::in(['active', 'archived'])],
            'sort' => ['nullable', Rule::in(self::SORTABLE)],
            'direction' => ['nullable', Rule::in(['asc', 'desc'])],
            'per_page' => ['nullable', 'integer', Rule::in([10, 25, 50])],
        ]);

        $sort = $filters['sort'] ?? 'updated_at';
        $direction = $filters['direction'] ?? 'desc';

        $products = Product::query()
            ->select(['id', 'name', 'sku', 'price', 'stock', 'status', 'updated_at'])
            ->when($filters['search'] ?? null, function ($query, string $search) {
                $query->where(function ($q) use ($search) {
                    $q->where('name', 'like', "%{$search}%")
                        ->orWhere('sku', 'like', "%{$search}%");
                });
            })
            ->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))
            ->orderBy($sort, $direction)
            ->orderBy('id', $direction) // stable tie-breaker
            ->paginate($filters['per_page'] ?? 25)
            ->withQueryString();

        return Inertia::render('Products/Index', [
            'products' => $products,
            'filters' => [
                'search' => $filters['search'] ?? '',
                'status' => $filters['status'] ?? '',
                'sort' => $sort,
                'direction' => $direction,
                'per_page' => (int) ($filters['per_page'] ?? 25),
            ],
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details worth calling out:

  • Rule::in(self::SORTABLE) means the sort column is always one you chose.
  • The second orderBy('id', ...) is a tie-breaker. Without it, rows with the same price can swap places between pages, and users see the same product on page 2 and page 3.
  • withQueryString() appends the current query string to the paginator's URLs, so next_page_url keeps the active filters.
  • The page receives a normalized filters prop, so the frontend never has to guess defaults.

Step 2: One pure function for the next query

The trickiest bug in table UIs is forgetting to reset the page. A user is on page 7, types a search term, and gets an empty table because the filtered result only has two pages. I keep that rule in a tiny pure function, which makes it easy to test without a browser.

// resources/js/lib/tableQuery.js
const FILTER_KEYS = ['search', 'status', 'per_page'];

export function buildQuery(current, patch) {
  const next = { ...current, ...patch };

  // Any filter change invalidates the current page number.
  const filterChanged = FILTER_KEYS.some(
    (key) => key in patch && patch[key] !== current[key],
  );
  if (filterChanged) {
    delete next.page;
  }

  // Keep the URL clean: drop empty values.
  return Object.fromEntries(
    Object.entries(next).filter(
      ([, value]) => value !== '' && value !== null && value !== undefined,
    ),
  );
}

export function nextSort(current, column) {
  if (current.sort !== column) {
    return { sort: column, direction: 'asc' };
  }
  return { sort: column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
}
Enter fullscreen mode Exit fullscreen mode

And a quick test with Node's built-in assert module:

import assert from 'node:assert/strict';
import { buildQuery, nextSort } from './tableQuery.js';

const cur = { search: '', status: 'active', sort: 'name', direction: 'asc', per_page: 25, page: 4 };

// A new search drops the page number
assert.deepEqual(buildQuery(cur, { search: 'bolt' }),
  { search: 'bolt', status: 'active', sort: 'name', direction: 'asc', per_page: 25 });

// Paging keeps the filters
assert.equal(buildQuery(cur, { page: 5 }).page, 5);

// Clearing a filter removes it from the URL
assert.equal('status' in buildQuery(cur, { status: '' }), false);

// Clicking the active column flips the direction
assert.deepEqual(nextSort(cur, 'name'), { sort: 'name', direction: 'desc' });
Enter fullscreen mode Exit fullscreen mode

Step 3: A small hook around router.get

Inertia's router.get(url, data, options) performs a GET visit and puts data in the query string. Four options make it feel like a local filter instead of a page load:

  • preserveState: true keeps component state, so the search input keeps its value while the user is typing.
  • preserveScroll: true stops the page from jumping to the top on every sort click.
  • replace: true replaces the current history entry, so typing "steel" doesn't create five Back-button steps.
  • only: ['products', 'filters'] is a partial reload: the server only needs to return the props that actually change.
// resources/js/hooks/useTableQuery.js
import { useEffect, useRef } from 'react';
import { router } from '@inertiajs/react';
import { buildQuery } from '@/lib/tableQuery';

export function useTableQuery(url, current) {
  const timer = useRef(null);

  useEffect(() => () => clearTimeout(timer.current), []);

  const visit = (patch) => {
    router.get(url, buildQuery(current, patch), {
      preserveState: true,
      preserveScroll: true,
      replace: true,
      only: ['products', 'filters'],
    });
  };

  const visitDebounced = (patch, delay = 300) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => visit(patch), delay);
  };

  return { visit, visitDebounced };
}
Enter fullscreen mode Exit fullscreen mode

The debounce is deliberately hand-rolled. It's ten lines, and it avoids pulling in a utility library for one function. The cleanup in useEffect cancels a pending visit if the user navigates away.

Step 4: The page component

// resources/js/Pages/Products/Index.jsx
import { useState } from 'react';
import { useTableQuery } from '@/hooks/useTableQuery';
import { nextSort } from '@/lib/tableQuery';

const COLUMNS = [
  { key: 'name', label: 'Name' },
  { key: 'sku', label: 'SKU' },
  { key: 'price', label: 'Price' },
  { key: 'stock', label: 'Stock' },
  { key: 'updated_at', label: 'Updated' },
];

export default function Index({ products, filters }) {
  const current = { ...filters, page: products.current_page };
  const { visit, visitDebounced } = useTableQuery('/products', current);
  const [search, setSearch] = useState(filters.search);

  const onSearch = (event) => {
    setSearch(event.target.value);
    visitDebounced({ search: event.target.value });
  };

  return (
    <div>
      <div className="toolbar">
        <input type="search" value={search} onChange={onSearch} placeholder="Search name or SKU" />
        <select value={filters.status} onChange={(e) => visit({ status: e.target.value })}>
          <option value="">All statuses</option>
          <option value="active">Active</option>
          <option value="archived">Archived</option>
        </select>
      </div>

      <table>
        <thead>
          <tr>
            {COLUMNS.map((col) => (
              <th key={col.key}>
                <button type="button" onClick={() => visit(nextSort(filters, col.key))}>
                  {col.label}
                  {filters.sort === col.key && (filters.direction === 'asc' ? ' ▲' : ' ▼')}
                </button>
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {products.data.length === 0 && (
            <tr>
              <td colSpan={COLUMNS.length}>No products match these filters.</td>
            </tr>
          )}
          {products.data.map((p) => (
            <tr key={p.id}>
              <td>{p.name}</td>
              <td>{p.sku}</td>
              <td>{p.price}</td>
              <td>{p.stock}</td>
              <td>{new Date(p.updated_at).toLocaleDateString()}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <nav className="pager">
        <button
          type="button"
          disabled={!products.prev_page_url}
          onClick={() => visit({ page: products.current_page - 1 })}
        >
          Previous
        </button>
        <span>
          Page {products.current_page} of {products.last_page} ({products.total} results)
        </span>
        <button
          type="button"
          disabled={!products.next_page_url}
          onClick={() => visit({ page: products.current_page + 1 })}
        >
          Next
        </button>
      </nav>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice that only the search box has local state. Everything else (status, sort, page) is read straight from props, which come from the URL. That is the whole point: refresh the page, paste the link in a chat, or hit Back, and the table shows exactly the same view.

Pagination uses current_page, last_page, total, prev_page_url and next_page_url, which are all part of Laravel's paginator JSON. If you prefer numbered page links, the paginator's URLs already include your filters thanks to withQueryString().

Common pitfalls

  • Sorting by user input. Never pass $request->sort to orderBy() without a whitelist. Validation with Rule::in() is the cheapest guard you'll ever write.
  • Forgetting the tie-breaker. Sorting by a non-unique column without a secondary id sort gives unstable pagination. Users notice duplicates and "missing" rows long before you do.
  • Not resetting the page. Any filter change should drop page. Put the rule in one function and test it.
  • preserveState off for search. Without it, component state is reset on every visit, so the search box's local value is overwritten while the user is still typing.
  • History spam. Without replace: true, every debounced keystroke becomes a history entry and the Back button becomes useless.
  • LIKE '%term%' on big tables. A leading wildcard can't use a normal B-tree index. It's fine for a few thousand rows; for larger tables, consider full-text search or a dedicated search engine before users feel the slowdown.
  • Leaking columns. paginate() serializes whatever you selected. Use an explicit select() (or an API resource) so internal fields don't end up in page props.

Summary

A server-driven table in Laravel and Inertia comes down to a few habits:

  1. The URL is the state. Filters, sort and page live in the query string.
  2. The controller validates everything and whitelists sortable columns.
  3. A pure buildQuery() function owns the rules, like resetting the page on filter changes, and is unit-tested.
  4. A small hook wraps router.get with preserveState, preserveScroll, replace and only.
  5. Stable sorting with an id tie-breaker keeps pagination honest.

No data-grid library, no separate API, and every view of your table is a link you can share.

What do you reach for first when a table outgrows the client: a hand-rolled pattern like this, or a full data-grid package?

Top comments (0)