Skip to content

SvelteKit URL state: filters, sorting and pagination

Justin Ahinon Updated

Resources

Original SvelteKit 1 demo (historical): https://github.com/JustinyAhin/okupter-repos/tree/main/apps/sveltekit-state-in-url

Examples checked: Svelte 5.55.0 / SvelteKit 2.55.0 · September 14, 2026

Put dashboard filters, sort order and pagination in the URL when users need to share a view, reload it, or return to it with Back. Read those parameters in server load and return both the selected controls and the matching rows. The URL then describes the view instead of competing with a second client-side store.

The original version of this article grew out of a filtering task on a WNBA project at my day job. This revision replaces the external countries API demo with a small, reproducible project list. It is demo data, not data from that client or Updraft.

The complete example below was type-checked and built with Svelte 5.55.0 and SvelteKit 2.55.0. HTTP tests cover filtering, sorting, pagination, empty results and malformed parameters. The original SvelteKit 1 repository remains linked as a historical example; it does not contain this revised code.

Decide what belongs in the URL

Our view uses q for name search, status for all/active/done, sort for asc/desc, and page for a positive integer. /dashboard?status=active&sort=desc&page=2 means the second page of active projects, ordered Z to A. Page size is fixed at two to make the demo easy to exercise.

Keep temporary presentation details, such as whether a tooltip is open, out of this contract. Do not put sensitive text or credentials in a URL. A shared URL can restore the filters, but it must never bypass authentication or tenant permissions.

For individual URL methods, use the Query parameter reference.

Filter, sort and paginate in server load

Create src/routes/dashboard/+page.server.ts:

import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

// Read-only demo data. Replace this with a user-scoped database query.
const projects = [
  { id: 1, name: 'Atlas', status: 'active' },
  { id: 2, name: 'Birch', status: 'done' },
  { id: 3, name: 'Cedar', status: 'active' },
  { id: 4, name: 'Delta', status: 'active' },
  { id: 5, name: 'Elm', status: 'done' }
];
const pageSize = 2;

const load = (({ url }) => {
  const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
  const status = url.searchParams.get('status') ?? 'all';
  const sort = url.searchParams.get('sort') ?? 'asc';
  const rawPage = url.searchParams.get('page') ?? '1';
  const pageNumber = Number(rawPage);

  if (!['all', 'active', 'done'].includes(status) || !['asc', 'desc'].includes(sort)) {
    error(400, 'Invalid filter or sort');
  }
  if (!/^\d+$/.test(rawPage) || !Number.isSafeInteger(pageNumber) || pageNumber < 1) {
    error(400, 'Page must be a positive integer');
  }

  const matches = projects
    .filter((project) => (status === 'all' || project.status === status)
      && project.name.toLowerCase().includes(q.toLowerCase()))
    .sort((a, b) => (sort === 'asc' ? 1 : -1) * a.name.localeCompare(b.name));
  const pages = Math.max(1, Math.ceil(matches.length / pageSize));
  if (pageNumber > pages) error(404, 'Page not found');

  const pageHref = (number: number) => {
    const next = new URL(url);
    next.searchParams.set('page', String(number));
    return next.pathname + next.search;
  };

  return {
    q, status, sort, pageNumber, pages, total: matches.length,
    projects: matches.slice((pageNumber - 1) * pageSize, pageNumber * pageSize),
    previous: pageNumber > 1 ? pageHref(pageNumber - 1) : null,
    next: pageNumber < pages ? pageHref(pageNumber + 1) : null
  };
}) satisfies PageServerLoad;

export { load };

The order matters: first filter the full matching set, then sort, then slice a page. Filtering only the current page would hide matches elsewhere. A real database implementation should do this work in the query rather than fetching the whole table into application memory.

The status and sort allowlists prevent unexpected modes. The page parser rejects fractions, negative numbers, junk suffixes and unsafe integers. A request beyond the last page returns 404; an empty search still has a valid first page with zero rows. These are explicit product choices, so change both the handler and UI if your application prefers to redirect an out-of-range page.

Only the returned page of records is serialized to the browser. The demo array is read-only; do not replace it with mutable module-level state shared by visitors. For real records, authenticate the request, restrict it to the current user or organization, use parameterized queries, and map sort options to trusted column names. Add a stable tie-breaker such as ID if names can repeat. Rapidly changing or very large datasets may need cursor pagination instead of offsets.

Use a GET form and real pagination links

Create src/routes/dashboard/+page.svelte:

<script lang="ts">
  import type { PageData } from './$types';
  let { data }: { data: PageData } = $props();
</script>

<h1>Projects</h1>
{#key `${data.q}:${data.status}:${data.sort}:${data.pageNumber}`}
  <form method="GET">
    <label>Search <input name="q" value={data.q} maxlength="100" /></label>
    <label>Status
      <select name="status" value={data.status}>
        <option value="all">All</option>
        <option value="active">Active</option>
        <option value="done">Done</option>
      </select>
    </label>
    <label>Name order
      <select name="sort" value={data.sort}>
        <option value="asc">A to Z</option>
        <option value="desc">Z to A</option>
      </select>
    </label>
    <button>Apply filters</button>
  </form>
{/key}

<p>{data.total} results. Page {data.pageNumber} of {data.pages}.</p>
<ul>
  {#each data.projects as project (project.id)}
    <li>{project.name} ({project.status})</li>
  {:else}
    <li>No projects match. Try another search or status.</li>
  {/each}
</ul>
<nav aria-label="Results pages">
  {#if data.previous}<a href={data.previous}>Previous</a>{/if}
  {#if data.next}<a href={data.next}>Next</a>{/if}
</nav>

The form deliberately omits page. Applying a new filter therefore returns to page 1. A GET submission replaces the query with the named form controls, so unrelated parameters are dropped here; include a validated hidden field if a parameter must survive. Pagination links preserve the current URL parameters and change only page.

The keyed block recreates the controls from the returned data when the URL selection changes, including Back and Forward navigation. Unsaved typing is a draft until Apply filters is submitted; a navigation can discard that draft. This is intentional for this small example.

A native GET form needs neither form actions nor remote functions: it reads data and navigates to a URL. It also works without JavaScript. With SvelteKit running, eligible internal navigation is handled by its router. Each applied view gets its own history entry; users can go back through their previous choices.

SvelteKit state management: storing state in the URL

What reruns, and where?

The four searchParams.get calls are load dependencies. When navigation changes one of them, SvelteKit can request fresh load data. A +page.server.ts load stays on the server during both initial requests and client navigation. The initial response includes rendered results; the client receives new data on later navigations.

SvelteKit load dependency tracking

Do not enable prerendering for this server-filtered page: its result depends on request query parameters. If a parent layout enables prerender, override it for this route. Do not use shallow pushState as a shortcut for refreshing server results; it changes history without the navigation this data flow needs.

SvelteKit page options

Exercise the view before adding more controls

Open /dashboard: Atlas and Birch should appear on page 1 of 3. Open /dashboard?status=active&sort=desc&page=2: Atlas is the only row, on page 2 of 2. Search for zzzz: show the empty state on page 1. Try page=0 or page=2x: expect 400. Try page=999: expect 404.

Then use the actual controls: choose Active, apply, move to Next, and use Back and Forward. Check that rows, labels and controls agree with the URL. Reload a filtered URL and repeat with JavaScript disabled. If you later add live typing, debounce navigation and decide whether typing should replace history entries; do not accidentally create one entry per character.

Taking this into a real dashboard

I built Updraft’s course-progress dashboard as part of the platform’s four-week MVP, alongside its REST API and database. The Updraft case study covers that scope. The filtering example above is a separate teaching example.

If you need filters, reporting or permissions added to an existing SvelteKit app, Discuss your project with the current data model and the view you want users to share. I can help scope the work before deciding whether a sprint fits.