Managing search parameters is key to creating dynamic, shareable, and bookmarkable pages. With the recent introduction of the Next.js App Router and related features in versions 13, 14, and 15, the handling of search parameters — also known as query strings or search params — and state management in React applications has never been easier.
Next.js has built-in routing capabilities, but handling complex search parameters can still be tricky. Whether you’re building a search interface, filtering and sorting content, or managing complex URL-based navigation, handling query strings properly ensures a good user experience and avoids issues like inconsistent states or broken URLs.
This article shows how we can use nuqs, a type-safe search param state manager library for Next.js, that allows us to store state in the URL by leveraging search parameters.
In this article, we’ll use the terms query strings, query params, search params, and search parameters interchangeably. Don’t worry — they all mean the same thing.
Query parameters, or query strings, are the part of a URL that comes after the ?
character. They consist of key-value pairs, where the key and value are separated by an =
symbol. In the following example, the query params are q
and pr
:
https://www.google.com/search?q=logrocket&pr=1
Query strings are an essential part of URLs. They allow the transmission of data between pages and applications. When properly parsed and managed, they allow for:
Search parameters in the URL enable deep linking and shareable states. However, without proper handling, they can lead to poor UX, especially when dealing with complex query structures or multiple data types (e.g., strings, numbers, Booleans, arrays). These challenges include proper encoding/decoding, type conversion, and maintaining a clean URL structure.
While Next.js provides native support for parsing and accessing query strings through router.query
, it lacks comprehensive features for managing these parameters in a clean and reusable way.
nuqs offers a more flexible and developer-friendly approach. It simplifies query string handling by providing a declarative API, enabling users to synchronize search parameters with React.state
without stress.
nuqs is useful because it abstracts away the low-level tasks of parsing, serializing, and managing query strings. It ensures type safety and supports common use cases like setting default values, managing multiple query keys, and updating URL params without navigating away from the page.
nuqs comes with features that make it a good choice for managing search parameters:
Using nuqs in your Next.js project offers several advantages:
To get started with nuqs, first, initialize a new Next.js app using create-next-app@latest
:
✔ What is your project named? … nuqs-tutorial ✔ Would you like to use TypeScript? … No / Yes ✔ Would you like to use ESLint? … No / Yes ✔ Would you like to use Tailwind CSS? … No / Yes ✔ Would you like to use `src/` directory? … No / Yes ✔ Would you like to use App Router? (recommended) … No / Yes ✔ Would you like to customize the default import alias (@/*)? … No / Yes
According to the nuqs documentation, you must select a certain version of the nuqs library for installation based on the version of Next.js you are using at the time:
If you’re using the latest version, navigate to the project folder, and install nuqs using the command below:
npm install nuqs@latest # or yarn add nuqs
Now wrap your {children}
with the NuqsAdapter
component in your root layout file:
import { NuqsAdapter } from 'nuqs/adapters/next/app' import { type ReactNode } from 'react' export default function RootLayout({ children }: { children: ReactNode }) { return ( <html> <body> <NuqsAdapter>{children}</NuqsAdapter> </body> </html> ) }
Easy peasy! Now, let’s explore how to use nuqs.
I’ve already created a product listing app that uses Next.js’ useSearchParams
to manage search parameters. We’ll use it in this tutorial to learn how nuqs simplifies handling search parameters. To follow along, you can clone the GitHub repo. There, you will see the type definitions, components, API calls, etc.
This is what the demo product listing app looks like:
To see full type definitions, components, API calls, integration of nuqs, etc., check out the full repo here.
I’ll be keeping the code to the essentials for the purpose of this article.
useQueryState
Hooknuqs allows you to manage local UI state by syncing it with the URL, ensuring that the search parameters are reflected in the browser’s address bar. It makes it possible by providing a useQueryState
Hook that can be used to replace React’s built-in useState
Hook.
This hook takes one required argument: the key to use in the query string. It returns an array with the value present in the query string as a string (or null
if none was found), and a state updater function.
Here’s a basic example of how to use the useQueryState
Hook:
import { useQueryState } from 'nuqs'; function SearchComponent() { const [search, setSearch] = useQueryState('search'); return ( <input value={search ?? ''} onChange={(e) => setSearch(e.target.value)} /> ); }
This simple example demonstrates how to create a search input that automatically updates the URL’s search parameter using nuqs.
Let’s see how we can use the useQueryState
Hook in our product listing app. We will create a custom hook where we will manage all our logic and reuse it across our codebase. In the lib
folder, create a file called hooks/useProductParams.ts
and add the following code. We’ll go over the details later:
import { useQueryState } from 'nuqs'; export function useProductParams() { const [search, setSearch] = useQueryState('search', { defaultValue: '', parse: (value) => value || '', history: 'push', }); return { search, setSearch, }; }
Here, we imported useQueryState
from nuqs and created a reusable custom hook, useProductParams
. We used the hook to define our URL parameter with several options:
'search'
is the name of the query parameter in the URL (e.g., ?search=clothes
)defaultValue
sets an empty string as the default valueparse
function handles incoming values, returning an empty string if the value is falsehistory: 'push'
means changes create new browser history entriesFinally, it returns both the current search value and the setter function.
Now replace the code in your SearchBar.tsx
file with the code below:
'use client' import { useProductParams } from '@/lib/hooks/useProductParams'; export default function SearchBar() { const { search, setSearch } = useProductParams(); const handleSearch = (term: string) => { setSearch(term); }; return ( <div className="relative"> <input type="text" value={search} onChange={(e) => handleSearch(e.target.value)} placeholder="Search products..." className="w-full p-2 border rounded-lg text-black" /> </div> ); }
Now, try searching for an item. As you type, you will see that nuqs automatically sets and updates the search parameter:
One issue with our code, though, is that nuqs does not automatically re-render our server components. This implies that if we perform any filtering on the server like, for example, pagination, we won’t notice any updates.
To address this, we will set the shallow
option to false
in our useQueryState
Hook:
// lib/hooks/useProductParams.ts const [search, setSearch] = useQueryState('search', { // other options shallow: false });
Now, if we type in the search bar, we will see that our products filter with every keystroke.
useQueryStates
There are scenarios where you may need to manage multiple related query parameters in your URL, especially when these parameters influence each other or when several need to be updated simultaneously.
For example, a user can filter by multiple criteria such as category, price range, etc., and sort by best rating or price value while maintaining pagination, with each filter represented as a query parameter in the URL.
nuqs provides the useQueryStates
Hook to handle this through synchronizing filter options, sorting, and pagination with URL query parameters. This ensures that changes to one filter don’t trigger unnecessary re-renders or inconsistencies, while also supporting batch updates for improved performance. Let’s see how to use it in our app.
In our useProductParams
Hook, update the code with the following:
// lib/hooks/useProductParams.ts import { useQueryState, useQueryStates } from 'nuqs'; export function useProductParams() { // other code const [{ category, sort, page }, setParams] = useQueryStates({ category: { defaultValue: '', parse: (value) => value || '', }, sort: { defaultValue: '', parse: (value) => value || '', }, page: { defaultValue: '1', parse: (value) => value || '1', }, }, { history: 'push', shallow: false }); const setCategory = (newCategory: string) => { setParams({ category: newCategory, page: '1' }); }; const setSort = (newSort: string) => { setParams({ sort: newSort, page: '1' }); }; const setPage = (newPage: string) => { setParams({ page: newPage }); }; return { // other variables category, setCategory, sort, setSort, page, setPage, }; }
This hook is similar to the useQueryState
Hook, but it takes an object as an argument where the keys are the query string keys and the values are the default values for the corresponding query state variables. The functions setCategory
, setSort
, and setPage
update their respective parameters and, where applicable, reset the pagination.
Now, update the following components to use the defined states.
FilterBar.tsx
:
// components/FilterBar.tsx 'use client' import { useProductParams } from '@/lib/hooks/useProductParams'; export default function FilterBar() { const { category, setCategory, sort, setSort } = useProductParams(); // rest of the code const handleCategoryChange = (value: string) => { setCategory(value); }; const handleSortChange = (value: string) => { setSort(value); }; return ( <div className="flex gap-4 mb-4"> <select value={category} onChange={(e) => handleCategoryChange(e.target.value)} // update to use handleCategoryChange className="p-2 border rounded-lg bg-blue-500" > // rest of the code </select> <select value={sort} onChange={(e) => handleSortChange(e.target.value)} // update to use handleSortChange className="p-2 border rounded-lg bg-blue-500" > // rest of the code </select> </div> ); }
Pagination.tsx
:
// components/FilterBar.tsx 'use client' import { useProductParams } from '@/lib/hooks/useProductParams'; interface PaginationProps { totalPages: number; } export default function Pagination({ totalPages }: PaginationProps) { const { page, setPage } = useProductParams(); const currentPage = Number(page); const handlePageChange = (newPage: number) => { setPage(newPage.toString()); }; return ( // rest of the code ); }
You can now test it out. Here is a demo:
Search parameters are strings by default, but managing more complex types (e.g., numbers, Booleans, dates) in URLs requires type-safe parsers.
nuqs provides built-in parsers like parseAsInteger
, parseAsBoolean
, and parseAsIsoDateTime
, ensuring that query parameters are validated and type-checked. For example, parseAsInteger
turns a string into an integer, while parseAsBoolean
interprets true
or false
.
These parsers help manage and enforce correct types in search params, improving safety and reliability in your apps. Let’s implement a built-in parser into our app with a default value to avoid doing null checks in the JSX directly, while also setting our previous configuration and keeping our codebase clean.
You might have noticed our options within hooks consist of the following:
const [search, setSearch] = useQueryState('search', { defaultValue: '', parse: (value) => value || '', history: 'push', });
Although this approach works, it has some limitations, such as the need to manually handle null or undefined cases, defaulting to an empty string as a fallback, and added verbosity. But with nuqs’ built-in parser, you can enjoy benefits like type safety, array handling, and JSON objects.
Update our custom hook code to incorporate the necessary parsers from nuqs:
// lib/hooks/useProductParams.ts import { useQueryState, useQueryStates, parseAsString, parseAsInteger } from 'nuqs'; export function useProductParams() { const [search, setSearch] = useQueryState('search', parseAsString.withDefault('').withOptions({ shallow: false, history: 'push' }) ); const [{ category, sort, page }, setParams] = useQueryStates({ category: parseAsString.withDefault(""), sort: parseAsString.withDefault(""), page: parseAsInteger.withDefault(1), }, { history: 'push', shallow: false }); // rest of the code }
You might also need to update the Pagination.tsx
component:
const currentPage = page; // remove the type cast Number was removed setPage(newPage); // toString() was removed
You can combine useQueryState
with the startTransition
function from React’s useTransition
to provide a smoother user experience by showing loading states while the server re-renders components. Let’s see this in action:
// lib/hooks/useProductParams.ts import { useTransition } from 'react'; export function useProductParams() { const [isPending, startTransition] = useTransition(); const [search, setSearch] = useQueryState('search', parseAsString.withDefault('').withOptions({ // rest of the code startTransition }) ); const [{ category, sort, page }, setParams] = useQueryStates({ // rest of the code }, { // rest of the code startTransition }); return { // rest of the code isPending }; }
We can now import the useProductParam
Hook and use it across our components:
'use client' import { useProductParams } from '@/lib/hooks/useProductParams'; import LoadingSpinner from './LoadingSpinner'; export default function SearchBar() { const { search, setSearch, isPending } = useProductParams(); // rest of the code return ( <div className="relative"> // rest of the code {isPending && ( <div className="absolute right-2 top-2"> <LoadingSpinner /> </div> )} </div> ); }
nuqs also manages type-safe search parameters on the server side, which is particularly useful for deeply nested server components.
nuqs offers a utility function called createSearchParamsCache
that lets you define parsers for specific search params (e.g., strings, integers) and access them safely within server components. The parsed values are maintained for the duration of the current render cycle and can be shared with client components to ensure type safety across the application. Let’s see how to use nuqs to implement this correctly.
Here is how we previously implemented search parameters on the server component without proper server-side handling:
import { Product, SearchParams } from './types/types'; export default async function ProductsPage({ searchParams, }: { searchParams: SearchParams; }) { const { products, totalPages } = await fetchProducts(searchParams); return ( // rest of the code ); }
This approach has limitations such as inconsistent default values, type safety issues, and undefined parameters during the initial render, as the searchParams
object is not properly validated. We will use the createSearchParamsCache
function to address this issue. It will enforce default values on the server side even if they are absent from the current URL.
Create a searchParamsCache
file for the search parameters configuration in the lib
folder:
// lib/searchParamsCache.ts import { createSearchParamsCache, parseAsString, parseAsInteger } from 'nuqs/server'; export const searchParamsCache = createSearchParamsCache({ search: parseAsString.withDefault(''), category: parseAsString.withDefault(''), sort: parseAsString.withDefault(''), page: parseAsInteger.withDefault(1) })
Then, use it in your server component:
// app/page.tsx import { searchParamsCache } from '@/lib/searchParamsCache'; import { SearchParams } from 'nuqs/server'; // rest of the import export default async function ProductsPage({ searchParams, }: { searchParams: Promise<SearchParams>; }) { const params = searchParamsCache.parse(await searchParams); // Fetch products with typed params const { products, totalPages } = await fetchProducts({ search: params.search, category: params.category, sort: params.sort, page: params.page }); return ( // rest of the code ); }
To add type-safe schema validation to your query parameters using Zod, we will need to modify the useProductParams
Hook. Here we will demonstrate how to create a custom parser and use Zod to validate our query parameters. We will only demonstrate this for the sort
option, but you can add validation for other options.
First, install Zod with npm install zod
, then define Zod schemas and validate sort options using Zod enums and create a custom parser that uses Zod for validation. In your useProductParams
file, add the code below:
import { useQueryState, useQueryStates, parseAsString, parseAsInteger, createParser } from 'nuqs'; const SortSchema = z.enum(['', 'price-asc', 'price-desc', 'rating']); type SortOption = z.infer<typeof SortSchema>; const zodSortParser = createParser({ parse: (value: string | null): SortOption => { const result = SortSchema.safeParse(value ?? ''); return result.success ? result.data : ''; }, serialize: (value: SortOption) => value, });
Now, inside the useProductParams
Hook, modify the sort
option to use the Zod-validated parser:
sort: zodSortParser.withDefault('' as SortOption),
Now update the code in our FilterBar.tsx
component:
// rest of the code const sortOptions = [ { value: '', label: 'Default' }, { value: 'price-asc', label: 'Price: Low to High' }, { value: 'price-desc', label: 'Price: High to Low' }, { value: 'rating', label: 'Best Rating' } ] as const; type SortOption = typeof sortOptions\[number\]['value']; export default function FilterBar() { // rest of the codd return ( <div className="flex gap-4 mb-4"> // rest of the code <select value={sort} onChange={(e) => handleSortChange(e.target.value as SortOption)} // update this className="p-2 border rounded-lg bg-blue-500" > // rest of the code </select> </div> ); }
Our sort filtering now benefits from Zod type safety.
While nuqs excels at managing URL-based states, it’s important to consider when to use it vs. other state management solutions. nuqs is useful when:
Alternatively, nuqs shouldn’t be considered when:
In this article, we explored how nuqs makes managing search parameters in Next.js applications much simpler. With its type-safe handling, custom serializers, and Zod integration, nuqs brings URL-based state management to the next level, helping you build applications that are easily shareable and SEO-friendly.
We covered setting up nuqs in a Next.js project, syncing filters, sorts, and pagination with URL parameters, and using built-in parsers to keep types consistent. By reducing boilerplate code and enhancing consistency, nuqs allows developers to focus on delivering a smooth user experience.
Whether you’re building a simple search feature or a full-blown ecommerce site, nuqs provides a streamlined, reusable way to handle query parameters and keep your code clean and organized.
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how Remix enhances SSR performance, simplifies data fetching, and improves SEO compared to client-heavy React apps.
Explore Fullstory competitors, like LogRocket, to find the best product analytics tool for your digital experience.
Learn how to balance vibrant visuals with accessible, user-centered options like media queries, syntax, and minimized data use.
Learn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.