Written by Jude Miracle✏️
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.
Why query string parsing matters
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:
- Improved user experience with shareable, bookmarkable URLs
- Better SEO by making content more discoverable
- Easier state management in complex applications
- Enhanced analytics and tracking capabilities
- Increased responsiveness in applications, especially with complex search or filter functionalities
- Insights into user behavior and preferences
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.
Why use nuqs?
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 features
nuqs comes with features that make it a good choice for managing search parameters:
- Type-safe: Leverages TypeScript for enhanced type safety
- Declarative API: Offers an intuitive interface for defining and using search parameters
- Server-side rendering (SSR) support: Works well with Next.js SSR capabilities
- Custom serializers: Offers flexible parsing and formatting of parameter values
- Transition API support: Enables smooth loading states during parameter updates
- Built-in parsing: Offers automatic parsing of search parameters into appropriate data types
- URL sync: Offers automatic synchronization between the URL and your application state
- History management: Properly handles browser history states
Benefits of using nuqs
Using nuqs in your Next.js project offers several advantages:
- Simplifies state management by reducing boilerplate code for handling URL parameters
- Improves code readability with declarative API, making the code more intuitive and easier to maintain
- Enhances performance by efficient parsing and updating of search parameters
- Offers a better developer experience because of its type-safety and integration with popular tools like Zod to improve the development process
- Can be seamlessly integrated into the Next.js ecosystem
Installing and setting up nuqs
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.
Basic nuqs usage
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.
Using the useQueryState
Hook
nuqs 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 value -
parse
function handles incoming values, returning an empty string if the value is false -
history: 'push'
means changes create new browser history entries
Finally, 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.
Managing multiple related query keys with 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:
Understanding type-safe search params: Parsers
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
Handling loading states with transitions
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>
);
}
Server-side usage of nuqs in server components
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
);
}
Integration with Zod
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.
When to use nuqs vs. Other state management solutions
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:
- You need to maintain stateful URLs for sharing or bookmarking
- SEO is a priority
- You want search engines to understand your page states
- Building a platform like an ecommerce shop where you want features like search, filtering, or pagination to naturally map to URL parameters
Alternatively, nuqs shouldn't be considered when:
- Dealing with complex, nested states that don’t map well to URL parameters
- Managing large amounts of data that would make URLs unwieldy
- Handling sensitive information that shouldn't be exposed in the URL
Conclusion
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.
LogRocket: Full visibility into production Next.js apps
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.
Top comments (1)
Thanks for this great article! I'm the author of nuqs.
To clarify something, the parse function always receives a string, never null or undefined or anything else, so you could simplify your logic a bit here.
Parsers are only called when there is something valid (this can include an empty string) to parse, and returning
null
from the parse function is what tells nuqs that the value can't be parsed, or is invalid for that data type somehow.