Back to home

Managing Advanced Search Param Filtering in the Next.js App Router

04 Oct 2025

Let’s say we want to have some kind of advanced filtering functionality in our Next.js app. For example, we might have a list of tasks and we want to filter them by category and name. We could also be wanting pagination, sorting, and other features.

It is a common request to put this state in the URL because the current state of the app can be shareable, bookmarkable and reloadable. But, it can be hard to coordinate state in the URL with component state using useEffect. Instead, it’s better to use the URL as a single source of truth—essentially lifting the state up, which is a well-known pattern in React.

However, when working with React Server Components and other new features in the Next.js App Router, it can be hard to manage this state smoothly. In this blog post, we will explore how to implement advanced search param filtering in the Next.js App Router, utilizing React 19 features like useOptimistic(), and finally switching to the library nuqs.


The Goal

The filters should provide instant user feedback, and they should not override each other when multiple filters are applied.


The First Attempt

We are working with a search component:

"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
// components/Search.tsx
export default function Search() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const q = searchParams.get("q") || "";
 
  return (
    <form className="relative flex w-full flex-col gap-1 sm:w-fit">
      <label className="font-semibold uppercase" htmlFor="search">
        Search
      </label>
      <input
        id="search"
        defaultValue={q}
        className="w-full pl-10 sm:w-96"
        name="q"
        placeholder="Search in task title or description..."
        type="search"
        onChange={(e) => {
          const newSearchParams = new URLSearchParams(searchParams.toString());
          newSearchParams.set("q", e.target.value);
          router.push(`?${newSearchParams.toString()}`);
        }}
      />
      <SearchStatus searching={false} />
    </form>
  );
}

And a category filter component:

"use client";
 
import { use } from "react";
import { useRouter, useSearchParams } from "next/navigation";
 
// components/CategoryFilter.tsx
export default function CategoryFilter({ categoriesPromise }: Props) {
  const categoriesMap = use(categoriesPromise);
  const searchParams = useSearchParams();
  const router = useRouter();
  const selectedCategories = searchParams.getAll("category");
 
  return (
    <div>
      <ToggleGroup
        toggleKey="category"
        options={Object.values(categoriesMap).map((category) => ({
          label: category.name,
          value: category.id.toString(),
        }))}
        selectedValues={selectedCategories}
        onToggle={(newCategories) => {
          const params = new URLSearchParams(searchParams);
          params.delete("category");
          newCategories.forEach((category) => {
            params.append("category", category);
          });
          router.push(`?${params.toString()}`);
        }}
      />
    </div>
  );
}

They are pushing the search and filter state to the URL, and then in a separate page.tsx server component we are using the filters to query the database directly and display the results in a table.

This is a logical implementation for a search and filter component, coding from a SPA perspective. However, the app is not working as expected. There are a few issues:

  • There is no way to know that the onChange for the search has been triggered, because the app is not searching instantly.
  • After we click a category, it takes time for the toggle button to become active.
  • The category filtering is not working as expected. When we click multiple filters quickly, only the last clicked category is applied.
  • When searching, then clicking a category before it’s completed, the search is thrown away (and vice versa).

The Reason for the Issues

It all comes down to the way the Next.js router works.

We click a category, but the URL does not update until the await in page.tsx doing the data fetching is resolved. The router is waiting for the server components to finish rendering on the server before it updates the URL. Since we are relying on the URL to be updated instantly, our implementation logic breaks.


Let’s begin by fixing the search component. We want to track the pending state of the search, so we can show a loading spinner when the search is being performed.

This one is pretty simple. We are already using an uncontrolled input and we can see our keystrokes updating, so all we need to do is use useTransition from React to track the pending state of the navigation. We can then use its isPending property to show a spinner:

"use client";
 
import { useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
 
// components/Search.tsx
export default function Search() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const q = searchParams.get("q") || "";
  const [isPending, startTransition] = useTransition();
 
  return (
    <form className="relative flex w-full flex-col gap-1 sm:w-fit">
      <label className="font-semibold uppercase" htmlFor="search">
        Search
      </label>
      <input
        id="search"
        defaultValue={q}
        className="w-full pl-10 sm:w-96"
        name="q"
        placeholder="Search in task title or description..."
        type="search"
        onChange={(e) => {
          const newSearchParams = new URLSearchParams(searchParams.toString());
          newSearchParams.set("q", e.target.value);
          startTransition(() => {
            router.push(`?${newSearchParams.toString()}`);
          });
        }}
      />
      <SearchStatus searching={isPending} />
    </form>
  );
}

Fixing the Category Filter

Next, let's track the pending state of the filtering. We can use the same useTransition hook around the push to the router. It is not suitable to put a spinner here, but we can put a data-pending attribute on the wrapper div and bind it to the pending state:

"use client";
 
import { use, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
 
// components/CategoryFilter.tsx
export default function CategoryFilter({ categoriesPromise }: Props) {
  const categoriesMap = use(categoriesPromise);
  const searchParams = useSearchParams();
  const router = useRouter();
  const selectedCategories = searchParams.getAll("category");
  const [isPending, startTransition] = useTransition();
 
  return (
    <div data-pending={isPending ? "" : undefined}>
      <ToggleGroup
        toggleKey="category"
        options={Object.values(categoriesMap).map((category) => ({
          label: category.name,
          value: category.id.toString(),
        }))}
        selectedValues={selectedCategories}
        onToggle={(newCategories) => {
          const params = new URLSearchParams(searchParams);
          params.delete("category");
          newCategories.forEach((category) => {
            params.append("category", category);
          });
          startTransition(() => {
            router.push(`?${params.toString()}`);
          });
        }}
      />
    </div>
  );
}

Then, we can use this data-pending attribute to update the UI using CSS. We can put a class group on a parent div in the root layout:

// app/layout.tsx
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="flex flex-col px-4 py-16 sm:px-16">
        <div className="group flex flex-col gap-10">
          {children}
        </div>
      </body>
    </html>
  );
}

And then use the group-has pseudo class to show a pulsing animation on the table in page.tsx when the filter is pending:

// app/page.tsx
export default async function Page() {
  return (
    <div className="overflow-x-auto rounded group-has-[[data-pending]]:animate-pulse">
      <table>{/* rendered table */}</table>
    </div>
  );
}

Instant Button Feedback with useOptimistic

However, we also want the category filter buttons to be instantly responsive.

This one is a bit harder. The filter is controlled by the URL, but we need to instantly update the toggled state of the button. You could try and add your own useState() and useEffect() to track the toggled state, but that would be a lot of work and it would be hard to keep in sync with the URL.

Instead, we can use the React 19 hook useOptimistic(). The way it works is that it takes in a state to show when nothing is pending, which can be our "true" state in the URL. Then, it returns a trigger function and an optimistic state. The hook creates a temporary optimistic state on the client. When the transition is completed, the optimistic state is thrown away and replaced with the "true" state:

"use client";
 
import { use, useOptimistic, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
 
// components/CategoryFilter.tsx
export default function CategoryFilter({ categoriesPromise }: Props) {
  const categoriesMap = use(categoriesPromise);
  const searchParams = useSearchParams();
  const router = useRouter();
  const selectedCategories = searchParams.getAll("category");
 
  const [isPending, startTransition] = useTransition();
  const [optimisticCategories, setOptimisticCategories] = useOptimistic(selectedCategories);
 
  return (
    <div data-pending={isPending ? "" : undefined}>
      <ToggleGroup
        toggleKey="category"
        options={Object.values(categoriesMap).map((category) => ({
          label: category.name,
          value: category.id.toString(),
        }))}
        selectedValues={optimisticCategories}
        onToggle={(newCategories) => {
          const params = new URLSearchParams(searchParams);
          params.delete("category");
          newCategories.forEach((category) => {
            params.append("category", category);
          });
 
          startTransition(() => {
            setOptimisticCategories(newCategories);
            router.push(`?${params.toString()}`);
          });
        }}
      />
    </div>
  );
}

This is pretty nice. We can instantly update the state of the button, and wait for the new page to load with the generated server components and the URL to update in the background.


Coordinating the Search and Filter

We still have a problem. When we search, then click a category before it’s settled, the search is thrown away (and vice versa). We need to coordinate the search and filter state.

To do that, we need to get them into the same transition and the same optimistic state. We could put the filters in the same component, or create a parent component, but to make it flexible and maintain composition, we should make a provider using React Context.

First, we define a filterSchema, which we can use to parse the search params from the URL. Then, we can define a Filters type from the schema and a context to hold and update the filter state. Now we also have type safety when updating and parsing the search params:

// lib/schema.ts
import { z } from "zod";
 
export const filterSchema = z.object({
  category: z.array(z.string()).default([]).optional(),
  q: z.string().default("").optional(),
});
 
export type Filters = z.infer<typeof filterSchema>;
 
export type FilterContextType = {
  filters: Filters;
  isPending: boolean;
  updateFilters: (updates: Partial<Filters>) => void;
};

A filter provider can hold the optimistic search params, and should be defined to always use the previous optimistic state when updating the state. Otherwise, we will not get the correct state when updating filters quickly:

"use client";
 
import { createContext, useContext, useOptimistic, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { filterSchema, Filters, FilterContextType } from "./schema";
 
export const FilterContext = createContext<FilterContextType | undefined>(undefined);
 
// components/FilterProvider.tsx
export default function FilterProvider({ children }: { children: React.ReactNode }) {
  const searchParams = useSearchParams();
  const router = useRouter();
 
  const filters = filterSchema.safeParse({
    category: searchParams.getAll("category"),
    q: searchParams.get("q") || undefined,
  });
 
  const [isPending, startTransition] = useTransition();
 
  const [optimisticFilters, setOptimisticFilters] = useOptimistic(
    filters.data,
    (prevState, newFilters: Partial<Filters>) => ({
      ...prevState,
      ...newFilters,
    })
  );
 
  function updateFilters(updates: Partial<typeof optimisticFilters>) {
    const newState = {
      ...optimisticFilters,
      ...updates,
    };
 
    const newSearchParams = new URLSearchParams();
 
    Object.entries(newState).forEach(([key, value]) => {
      if (Array.isArray(value)) {
        value.forEach((v) => newSearchParams.append(key, v));
      } else if (value !== undefined) {
        newSearchParams.set(key, value);
      }
    });
 
    startTransition(() => {
      setOptimisticFilters(updates || {});
      router.push(`?${newSearchParams.toString()}`);
    });
  }
 
  return (
    <FilterContext.Provider
      value={{
        filters: optimisticFilters || {},
        isPending,
        updateFilters,
      }}
    >
      {children}
    </FilterContext.Provider>
  );
}
 
export function useFilters() {
  const context = useContext(FilterContext);
  if (!context) {
    throw new Error("useFilter must be used within a FilterProvider");
  }
  return context;
}

The filters are now super easy to use:

"use client";
 
import { useTransition } from "react";
import { useFilters } from "./FilterProvider";
 
// components/Search.tsx
export default function Search() {
  const { filters, updateFilters } = useFilters();
  const [isPending, startTransition] = useTransition();
 
  return (
    <form className="relative flex w-full flex-col gap-1 sm:w-fit">
      <label className="font-semibold uppercase" htmlFor="search">
        Search
      </label>
      <input
        id="search"
        autoComplete="off"
        defaultValue={filters.q}
        className="w-full pl-10 sm:w-96"
        name="q"
        placeholder="Search in task title or description..."
        type="search"
        onChange={(e) => {
          startTransition(() => {
            updateFilters({ q: e.target.value });
          });
        }}
      />
      <SearchStatus searching={isPending} />
    </form>
  );
}
"use client";
 
import { use, useTransition } from "react";
import { useFilters } from "./FilterProvider";
 
// components/CategoryFilter.tsx
export default function CategoryFilter({ categoriesPromise }: Props) {
  const categoriesMap = use(categoriesPromise);
  const { filters, updateFilters } = useFilters();
  const categories = filters.category || [];
  const [isPending, startTransition] = useTransition();
 
  return (
    <div data-pending={isPending ? "" : undefined}>
      <ToggleGroup
        toggleKey="category"
        options={Object.values(categoriesMap).map((category) => ({
          label: category.name,
          value: category.id.toString(),
        }))}
        selectedValues={categories}
        onToggle={(newCategories) => {
          startTransition(() => {
            updateFilters({ category: newCategories });
          });
        }}
      />
    </div>
  );
}

Note that we added an additional useTransition hook to track the pending state of each filtering. This is because we don’t want to show the spinner when the categories are being updated, and vice versa.

After implementing the above changes, the app is working as expected. The search and filter are instantly responsive, and they do not override each other when multiple filters are applied.


Switching to nuqs

While this solution is nice, it’s often unnecessary to write your own serialized state manager by hand. Instead, we can use a library that does this for us.

nuqs is a library that provides a type-safe way to manage search params as state in React.

First, we mount the NuqsAdapter for Next.js in our root layout:

// app/layout.tsx
import { NuqsAdapter } from "nuqs/adapters/next/app";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="flex flex-col px-4 py-16 sm:px-16">
        <NuqsAdapter>{children}</NuqsAdapter>
      </body>
    </html>
  );
}

Next, define a global search param configuration:

// lib/searchParams.ts
import {
  parseAsString,
  createSearchParamsCache,
  parseAsArrayOf,
} from "nuqs/server";
 
export const searchParams = {
  category: parseAsArrayOf(parseAsString).withDefault([]),
  q: parseAsString.withDefault(""),
};
 
export const searchParamsCache = createSearchParamsCache(searchParams);

Then, we can use the useQueryState hook to get and update the search params in any component.

The way nuqs is implemented, the search params are actually pushed to the URL instantly. To trigger the page to reload with the result from the server, we set the option shallow: false.

Finally, we can pass startTransition to the useQueryState options, and use the pending state of the transitions to display user feedback as we did before:

"use client";
 
import { useTransition } from "react";
import { useQueryState } from "nuqs";
import { searchParams } from "./searchParams";
 
// components/Search.tsx
export default function Search() {
  const [isPending, startTransition] = useTransition();
  const [q, setQ] = useQueryState(
    "q",
    searchParams.q.withOptions({
      shallow: false,
      startTransition,
    })
  );
 
  return (
    <form className="relative flex w-full flex-col gap-1 sm:w-fit">
      <label className="font-semibold uppercase" htmlFor="search">
        Search
      </label>
      <input
        id="search"
        autoComplete="off"
        defaultValue={q}
        className="w-full pl-10 sm:w-96"
        name="q"
        placeholder="Search in task title or description..."
        type="search"
        onChange={(e) => setQ(e.target.value)}
      />
      <SearchStatus searching={isPending} />
    </form>
  );
}

And update CategoryFilter:

"use client";
 
import { use, useTransition } from "react";
import { useQueryState } from "nuqs";
import { searchParams } from "./searchParams";
 
// components/CategoryFilter.tsx
export default function CategoryFilter({ categoriesPromise }: Props) {
  const categoriesMap = use(categoriesPromise);
  const [isPending, startTransition] = useTransition();
 
  const [categories, setCategories] = useQueryState(
    "category",
    searchParams.category.withOptions({
      shallow: false,
      startTransition,
    })
  );
 
  return (
    <div data-pending={isPending ? "" : undefined}>
      <ToggleGroup
        toggleKey="category"
        options={Object.values(categoriesMap).map((category) => ({
          label: category.name,
          value: category.id.toString(),
        }))}
        selectedValues={categories}
        onToggle={setCategories}
      />
    </div>
  );
}

The result is really nice! Notice the difference from the provider example: here the search params are instantly updated in the URL and not after the navigation, and then the page is reloaded with the result as before.


Conclusion

In this blog post, we explored how to implement advanced search param filtering in the Next.js App Router:

  1. We learned how to track the pending state of search inputs with useTransition().
  2. We implemented an instantly responsive category filter with React 19's useOptimistic().
  3. We coordinated the search and filter state together using a React Context provider to eliminate race conditions.
  4. Finally, we switched to nuqs for a type-safe, production-ready solution that eliminates boilerplate while keeping every view shareable and bookmarkable.