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
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>
);
}
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),
};
}
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>
);
}
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>
);
}
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' });
// lib/queries/orders.ts
if (search) {
query.$text = { $search: search };
}
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)