Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Search, Filter & Paginate

Debounced search, filters in the URL and paginating server-side results.

A searchable, filterable, paginated list is probably the most common screen in business apps: admin tables, product catalogues, inboxes, task boards. This lesson builds one piece by piece against this site's real /api/demo/tasks endpoint. You'll cover:

  • searching in the browser and on the server
  • debouncing input
  • filtering on the server
  • paging through results
  • keeping it all in the URL

Client-side search with computed

When the whole list is already in the browser (tens or a few hundred items), search it there. It's instant and needs no server. A computed re-filters whenever the query or the list changes.

Normalise both sides, with trim() and toLowerCase(), so " RATE " still matches "Rate limit…".

Debouncing: wait until the user stops typing

When each keystroke triggers a server request, typing "rate limit" sends ten requests, and most of their responses are thrown away. A debounce waits until the input has been quiet for a moment (say 300 ms) and then fires once. Type quickly in the box below and compare the counters.

Debounce fires once, after the input pauses: use it for search boxes. Throttle fires at most once every N ms while events keep coming: use it for scroll and resize handlers. VueUse's refDebounced and useDebounceFn package both.

Server-side filters with query parameters

Once the data is too big to send to the browser, the server filters it. The Laravel endpoint accepts search, priority, status and project as query parameters. Try a few:

On the Laravel side, each filter is validated and then applied only if present, using when(). search becomes a LIKE '%term%' match on the title via whereLike() (from app/Http/Controllers/Api/TaskController.php):

$filters = $request->validate([
    'priority' => ['nullable', Rule::enum(TaskPriority::class)],
    'status' => ['nullable', Rule::in(['open', 'completed'])],
    'project' => ['nullable', 'string', 'exists:projects,slug'],
    'search' => ['nullable', 'string', 'max:100'],
]);

$tasks = Task::query()
    ->with(['project', 'assignee', 'tags'])
    ->when($filters['priority'] ?? null, fn ($query, string $priority) => $query->priority(TaskPriority::from($priority)))
    ->when(($filters['status'] ?? null) === 'open', fn ($query) => $query->open())
    ->when(($filters['status'] ?? null) === 'completed', fn ($query) => $query->completed())
    ->when($filters['project'] ?? null, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
    ->when($filters['search'] ?? null, fn ($query, string $search) => $query->whereLike('title', "%{$search}%"))
    ->orderBy('id')
    ->paginate(10)
    ->withQueryString();

In Vue, keep the filters in one reactive object and turn them into a query string with URLSearchParams, skipping empty values:

Watching the computed url instead of filters gives you a single place where "something changed, refetch" happens, and it skips refetching when a change produces the same URL.

Pagination with Laravel's meta

paginate(10) returns ten rows per page, plus links and meta describing where you are. The meta block is all a Vue pager needs:

meta field Use it for
current_page, last_page "Page 2 of 5", and disabling Previous/Next at the ends
from, to, total "Showing 11–20 of 47"
per_page page-size display
links ready-made numbered page buttons (labels include « entities)
Reset to page 1 when a filter changes. If you're on page 3 and choose "High priority", which only has one page, page 3 is empty. Watch the filters and set page = 1, as the complete example below does.

Keeping state in the URL

Users expect to bookmark, share and press Back on a filtered list. Store the search, filters and page in the URL's query string. With Vue Router that's route.query and router.replace:

import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// Initial state comes from the URL…
const filters = reactive({
  search: route.query.search ?? '',
  priority: route.query.priority ?? '',
  page: Number(route.query.page ?? 1),
})

// …and every change is written back (replace: no history entry per keystroke)
watch(filters, () => {
  const query = Object.fromEntries(Object.entries(filters).filter(([, v]) => v && v !== 1))
  router.replace({ query })
}, { deep: true })

Without a router, history.replaceState and URLSearchParams(location.search) do the same job. The demo below uses them: change a filter and watch this page's address bar, then reload and the filters come back.

Putting it together: a task explorer

This combines everything above:

  • debounced search, sent to the server as ?search=
  • server-side priority, status and project filters
  • a reset to page 1 when filters change
  • a pager built from meta
  • state kept in the URL
  • loading and empty states

Search, filters and page all live in one computed URL. The debounced search text is just another query parameter, so the server searches all tasks, not only the visible page. Typing also resets to page 1, like any other filter.

Search for "api" with Any priority, then switch to High: the server combines both conditions in one query (whereLike plus the priority scope), and the pager's totals update to match.

Key takeaways

  • Small lists: filter in the browser with computed. Large lists: send search and filters as query parameters and let Laravel filter with when() and whereLike().
  • Debounce search input so a request goes out only when typing pauses.
  • Build the request URL in a single computed from all filters, and watch that one value to fetch.
  • Use meta from Laravel's paginator for page counts and "showing X–Y of Z", and reset to page 1 when filters change.
  • Keep search, filters and page in the URL (router.replace({ query })) so lists can be bookmarked and shared.