DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Filtering, and Pagination in Next.js 15 The Pattern I Actually Use

Every dashboard eventually needs the same three things. A search box, a few filters, and pagination once the list gets long. The version I used to build kept all of that in useState, which meant losing your filters the moment you refreshed the page or shared a link with someone else.

Keeping this state in the URL instead fixed both problems at once. Here is the setup.


1. Why URL Search Params, Not useState

State in useState disappears on refresh and cannot be shared or bookmarked. The same filtered, paginated view living in the URL means a refresh keeps your place, a shared link shows the same filtered results to whoever opens it, and the browser's back button actually works the way people expect.

/dashboard/orders?search=invoice&status=pending&page=2
Enter fullscreen mode Exit fullscreen mode

This URL alone fully describes the current view. No client state needs to reconstruct it.


2. Reading Search Params in a Server Component

// app/dashboard/orders/page.tsx
import { getOrders } from '@/lib/queries/orders';

interface PageProps {
  searchParams: Promise<{
    search?: string;
    status?: string;
    page?: string;
  }>;
}

export default async function OrdersPage({ searchParams }: PageProps) {
  const { search, status, page } = await searchParams;
  const currentPage = Number(page) || 1;

  const { orders, totalPages } = await getOrders({
    search,
    status,
    page: currentPage,
  });

  return (
    <div>
      <OrderFilters />
      <OrderList orders={orders} />
      <Pagination currentPage={currentPage} totalPages={totalPages} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The page itself stays a Server Component, fetching exactly the data described by the current URL. No loading state needed here either, Next.js handles the transition with loading.tsx if the query is slow.


3. The Query Function

// lib/queries/orders.ts
import { connectDB } from '@/lib/db';
import Order from '@/models/Order';

interface GetOrdersParams {
  search?: string;
  status?: string;
  page: number;
}

const PAGE_SIZE = 20;

export async function getOrders({ search, status, page }: GetOrdersParams) {
  await connectDB();

  const query: Record<string, any> = {};

  if (search) {
    query.customerName = { $regex: search, $options: 'i' };
  }

  if (status) {
    query.status = status;
  }

  const skip = (page - 1) * PAGE_SIZE;

  const [orders, total] = await Promise.all([
    Order.find(query).sort({ createdAt: -1 }).skip(skip).limit(PAGE_SIZE).lean(),
    Order.countDocuments(query),
  ]);

  return {
    orders,
    totalPages: Math.ceil(total / PAGE_SIZE),
  };
}
Enter fullscreen mode Exit fullscreen mode

Running find and countDocuments in parallel with Promise.all matters here, they are independent queries and there is no reason to wait for one before starting the other.


4. Updating the URL from Filter Inputs

The filter component itself is a Client Component, since it responds to user interaction, but it never holds the actual filtered data. It only ever updates the URL, and the Server Component above reacts to that.

// components/OrderFilters.tsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useState, useEffect, useTransition } from 'react';

export function OrderFilters() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();

  const [searchInput, setSearchInput] = useState(searchParams.get('search') ?? '');

  function updateParams(updates: Record<string, string | null>) {
    const params = new URLSearchParams(searchParams.toString());

    Object.entries(updates).forEach(([key, value]) => {
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
    });

    params.set('page', '1'); // reset to page 1 on any filter change

    startTransition(() => {
      router.push(`${pathname}?${params.toString()}`);
    });
  }

  // Debounce the search input so it doesn't refetch on every keystroke
  useEffect(() => {
    const timeout = setTimeout(() => {
      updateParams({ search: searchInput || null });
    }, 400);

    return () => clearTimeout(timeout);
  }, [searchInput]);

  return (
    <div>
      <input
        value={searchInput}
        onChange={(e) => setSearchInput(e.target.value)}
        placeholder="Search orders..."
      />

      <select
        defaultValue={searchParams.get('status') ?? ''}
        onChange={(e) => updateParams({ status: e.target.value || null })}
      >
        <option value="">All statuses</option>
        <option value="pending">Pending</option>
        <option value="completed">Completed</option>
      </select>

      {isPending && <span>Updating...</span>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here. The 400ms debounce on the search input stops a fetch from firing on every keystroke, only after the user pauses typing. And resetting page to 1 on any filter change avoids landing on an empty page 4 after a new filter reduces the total result count below that.


5. Pagination Controls

// components/Pagination.tsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';

interface PaginationProps {
  currentPage: number;
  totalPages: number;
}

export function Pagination({ currentPage, totalPages }: PaginationProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  function goToPage(page: number) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('page', String(page));
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <div>
      <button disabled={currentPage <= 1} onClick={() => goToPage(currentPage - 1)}>
        Previous
      </button>
      <span>Page {currentPage} of {totalPages}</span>
      <button disabled={currentPage >= totalPages} onClick={() => goToPage(currentPage + 1)}>
        Next
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Reusing the existing search params and only changing page keeps the current search and filters intact when navigating between pages, instead of accidentally clearing them.


6. Combining Search with a Real Text Index

A $regex search works fine for small collections, but scans the full collection on every query as it grows. For anything beyond a few thousand documents, a real text index performs much better.

// models/Order.ts
OrderSchema.index({ customerName: 'text' });
Enter fullscreen mode Exit fullscreen mode
// lib/queries/orders.ts
if (search) {
  query.$text = { $search: search };
}
Enter fullscreen mode Exit fullscreen mode

MongoDB's text index handles this far more efficiently than a regex scan once the collection grows, at the cost of slightly less flexible partial matching than regex offers.


Summary

Pattern Handles
URL search params instead of useState Shareable, bookmarkable, refresh-safe filtered views
Server Component reading searchParams Data fetching that reacts directly to the URL
Debounced input before updating the URL Avoiding a fetch on every keystroke
Reset page to 1 on filter change Avoiding landing on an empty page after filtering
Promise.all for count and results Running independent queries in parallel
Text index over regex at scale Performance once the collection grows beyond a few thousand records

The shift that matters most here: the URL is the state. The Server Component just reads it and fetches accordingly, and the filter UI's only job is updating that URL, never holding the actual result data itself.

I use this exact URL-driven pattern, debounced search, filters, pagination, across every dashboard and admin panel I build.

See it in a real codebase: https://neurodash-dashbord.vercel.app/

Get the templates: https://pixelanas.gumroad.com

Do you keep filter state in the URL, or in a client store? Drop it below ๐Ÿ‘‡


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)