Components with Search Params as State
Ausath Ikram •
I often find myself building these components so many times for so many use cases:
- Search input
- Pagination
- Filter select
All of them utilizes URL search params as a state manager.
There are a lot of reasons why and it probably needs a blog post on its own, but I like this one the most:
“The URL is the first UI your users will see”
— Francois Best, nuqs
Other reasons includes sharing, bookmarks, browser history, back/forward navigations, and more.1
Combining HTML form elements (<input> and <select>) with APIs that exposes search params are a great way to implement state management with search params.
With these 3 Next.js client component hooks:
useSearchParamslets us read the current URL's query string. It returns a read-only version of theURLSearchParamsinterface.2usePathnamelets us read the current URl's pathname.3useRouterallows us to programmatically change routes inside client component.4
We can build a reusable React client components that manages their state with URL search params.
I found this pattern for the first time when learning Next.js in Learn Next.js and have been using it ever since.
Search Input
There are two ways to implement this: updating the state when input changes (with debounce) and form submission.
The latter is more straight forward. We can use the <Form> component from next/form.
import Form from 'next/form';
export default function Page() {
return (
<Form action="/explore">
<input name="q" type="search" />
<button type="submit">Search</button>
</Form>
);
}
The name attribute of the <input> element is important here. On form submission (hitting enter or clicking the submit button), this will do a client-side navigation and append the value of your input to the URL (e.g. /explore?q=anything).5
Next.js <Form> also prefetch the loading UIs for that route when the component is within the user's viewport.5
For the second approach, we want to sync the URL with our <input>. To do that we will need to use some hooks.
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
type SearchInputProps = {
name: string;
} & React.ComponentProps<'input'>;
export function SearchInput({ name, ...props }: SearchInputProps) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const handleSearch = (term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set(name, term);
} else {
params.delete(name);
}
router.replace(`${pathname}?${params.toString()}`);
};
return (
<input
defaultValue={searchParams.get(name) ?? ''}
name={name}
onChange={(e) => handleSearch(e.target.value)}
type="search"
{...props}
/>
);
}
This will update the URL everytime the input value changes, but most of the time we don't want that. A quick fix is to use debouncing.
You can make a custom hook yourself, but I usually just use use-debounce because of its small size (< 1kb)6 and avoid writing more boilerplates.
import { useDebouncedCallback } from 'use-debounce';
const DEBOUNCE_MS = 300;
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set(name, term);
} else {
params.delete(name);
}
router.replace(`${pathname}?${params.toString()}`);
}, DEBOUNCE_MS);
Final code:
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
type SearchInputProps = {
name: string;
} & React.ComponentProps<'input'>;
const DEBOUNCE_MS = 300;
export function SearchInput({ name, ...props }: SearchInputProps) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set(name, term);
} else {
params.delete(name);
}
router.replace(`${pathname}?${params.toString()}`);
}, DEBOUNCE_MS);
return (
<input
defaultValue={searchParams.get(name) ?? ''}
name={name}
onChange={(e) => handleSearch(e.target.value)}
type="search"
{...props}
/>
);
}
A slight delay can make a huge difference in user experience.
Using it:
import { Table } from '@/components/table';
import { getItems } from '@/lib/data';
export default async function Page({ searchParams }: PageProps<'/explore'>) {
const { q } = await searchParams;
const data = await getItems(q);
return (
<div>
<SearchInput name="q" />
<Table data={data} />
</div>
);
}
Remember to set the name props to whatever you want as the query key.
Pagination
Start by creating this helper function:
function generatePagination(currentPage: number, totalPages: number) {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (currentPage <= 3) {
return [1, 2, 3, '…', totalPages - 1, totalPages];
}
if (currentPage >= totalPages - 2) {
return [1, 2, '…', totalPages - 2, totalPages - 1, totalPages];
}
return [
1,
'…',
currentPage - 1,
currentPage,
currentPage + 1,
'…',
totalPages,
];
}
This will be useful to make the component reusable.
'use client';
import { useSearchParams, usePathname } from 'next/navigation';
function generatePagination(currentPage: number, totalPages: number) {
// Previous logic goes here…
}
type PaginationProps = {
paginationMeta: {
// Other meta like hasNext, hasPrev, etc. would also be useful…
totalPages: number;
};
} & React.ComponentProps<'div'>;
export function Pagination({ paginationMeta, ...props }: PaginationProps) {
const searchParams = useSearchParams();
const pathname = usePathname();
const currentPage = Number(searchParams.get('page') ?? '1');
const allPages = generatePagination(currentPage, paginationMeta.totalPages);
const createPageUrl = (page: number | string) => {
const params = new URLSearchParams(searchParams);
if (typeof page === 'number') {
const safePage = Math.min(Math.max(page, 1), paginationMeta.totalPages);
params.set('page', safePage.toString());
}
return `${pathname}?${params.toString()}`;
};
return (
<div {...props}>
<a href={createPageUrl(currentPage - 1)}>Previous</a>
{allPages.map((page) =>
page === '…' ? (
<div key={`ellipsis-${page}`}>{page}</div>
) : (
<a href={createPageUrl(page)} key={page}>
{page}
</a>
)
)}
<a href={createPageUrl(currentPage + 1)}>Next</a>
</div>
);
}
A caveat to this is that you need to make sure and adjust your pagination meta so it matches what you actually have from your data source.
import { Pagination } from '@/components/pagination';
import { Table } from '@/components/table';
import { getItems } from '@/lib/data';
export default async function Page({ searchParams }: PageProps<'/explore'>) {
const { page } = await searchParams;
// Make sure this returns all the pagination meta we need
const { data, meta } = await getItems(page);
return (
<div>
<Table data={data} />
<Pagination paginationMeta={meta} />
</div>
);
}
You can also use other variables from your paginationMeta to disable the previous and next link. See the full example here.
Filter Select
This one uses the same pattern as search input.
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
type Option = {
value: string;
label: string;
};
type FilterSelectProps = {
name: string;
opts: Option[];
} & React.ComponentProps<'select'>;
export function FilterSelect({ name, opts, ...props }: FilterSelectProps) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const handleChange = (opt: string) => {
const params = new URLSearchParams(searchParams);
if (opt) {
params.set(name, opt);
} else {
params.delete(name);
}
router.replace(`${pathname}?${params.toString()}`);
};
return (
<select
defaultValue={searchParams.get(name) ?? ''}
name={name}
onChange={(e) => handleChange(e.target.value)}
{...props}
>
{opts.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
You can use it like this:
export default function Page() {
const statusOpts = [
{ label: 'Select Status', value: '' },
{ label: 'All', value: 'all' },
{ label: 'Todo', value: 'todo' },
{ label: 'Pending', value: 'pending' },
{ label: 'Completed', value: 'completed' },
];
const sortOpts = [
{ label: 'Sort By', value: '' },
{ label: 'ID', value: 'id' },
{ label: 'Title', value: 'title' },
{ label: 'Date Completed', value: 'completed_at' },
];
return (
<div>
<FilterSelect name="status" opts={statusOpts} />;
<FilterSelect name="sort_by" opts={sortOpts} />;
</div>
);
}
Like the first Search Input we made using the <Form> component earlier, the name attribute is important here as it determines what type of filter we want to append to the URL.
Other Frameworks
The technique here is not unique to Next.js, you can apply the same thing with other frameworks (given a similar API). For example, the same code but with react-router:
import { useSearchParams } from 'react-router';
import { useDebouncedCallback } from 'use-debounce';
type SearchInputProps = {
name: string;
} & React.ComponentProps<'input'>;
const DEBOUNCE_MS = 300;
export function SearchInput({ name, ...props }: SearchInputProps) {
const [searchParams, setSearchParams] = useSearchParams();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set(name, term);
} else {
params.delete(name);
}
setSearchParams(params);
}, DEBOUNCE_MS);
return (
<input
defaultValue={searchParams.get(name) ?? ''}
name={name}
onChange={(e) => handleSearch(e.target.value)}
type="search"
{...props}
/>
);
}
nuqs
You are also most likely be able to implement this components in your codebase with nuqs as it is a dedicated, type-safe search params state management library for React.7
'use client';
import { useQueryState } from 'nuqs';
type SearchInputProps = {
name: string;
} & React.ComponentProps<'input'>;
export function SearchInput({ name, ...props }: SearchInputProps) {
// Create a query state for `q`, with default as empty string
const [q, setQ] = useQueryState('q', { defaultValue: '' });
return (
<input
name={name}
onChange={(e) => setQ(e.target.value)}
type="search"
value={q} // sync input value to query state
{...props}
/>
);
}
I haven't tried it so I asked ChatGPT to do it and it does look way more simpler.