DEV Community

Cover image for Managing search parameters in Next.js with nuqs
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Managing search parameters in Next.js with nuqs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Next.js Versions When Using Nuqs

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

Product Listing App

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)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, try searching for an item. As you type, you will see that nuqs automatically sets and updates the search parameter:

Filtering The Product Listing Page

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
  });
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

You can now test it out. Here is a demo:

Demonstration Of Pagination

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',
  });
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

Now, inside the useProductParams Hook, modify the sort option to use the Zod-validated parser:

sort: zodSortParser.withDefault('' as SortOption),
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 Next.js Demonstration

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)

Collapse
 
franky47 profile image
François Best

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.