
Building a Search Component in Next.js App Router
M. Zakyuddin Munziri
@zakiego
Originally written in Bahasa Indonesia.
Abstract
Building a Search component is somewhat tricky, and since the emergence of App Router, it has become quite challenging.
Through this article, we will learn how to create a <Search/> component that, on every change, triggers a change in search params, like /search?query=something.
We will use next-query-params to easily connect it to search params. Then we'll use use-debounce to add debounce. And finally, we'll make it more realistic by fetching from a dummy API, with validation using zod.
You can view the code in the repository zakiego/seach-params-playground
And you can try it directly at seach-params-playground.vercel.app
Introduction
There are many ways to create a search component. The most popular way is to use onChange from an <input/> component, then store it with useState.
Unfortunately, when using Next.js App Router, we cannot simply use useState. Because server components do not allow that.
Therefore, through this article, we will learn to create an <Input/> component and connect it directly to searchParams.
What is searchParams?
The simplest example: when we type in the search field, the URL changes from /search to search?query=bicycle. That query is what we call searchParams.
The value of searchParams can be obtained from every page.tsx file. It can be a string, an array of strings, or undefined.
// https://nextjs.org/docs/app/api-reference/file-conventions/page
export default function Page({
params,
searchParams,
}: {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}) {
return <h1>My Page</h1>
}
Simple Input Component
First, we will create the /search page. To do that, create the file /src/app/search/page.tsx.
// src/app/search/page.tsx
interface Props {
searchParams: {
query?: string;
};
}
export default function Page(props: Props) {
const { searchParams } = props;
const { query } = searchParams;
return (
<div>
<div>Query: {query}</div>
<input className="ring-1 ring-gray-300 rounded-md" />
</div>
);
}
From the code above, we get searchParams from props, then extract query from searchParams.
Actually, searchParams can be named anything, for example ?q= or ?keyword, it's up to us.
If we were using a client component, we could directly get the value from <input/> using onChange. Here's an example:
<input
onChange={(e) => {
console.log(e.target.value);
}}
/>
However, applying this to a server component (the default for page.tsx) will result in an error.

Solution
To work around this, we will move the <input> component into a client component. Create the file /src/app/search/client.tsx. We will collect all client components here.
Add "use client" at the very top of the code, to make all components in this file client components.
"use client";
export const SearchInput = () => {
return (
<input
className="ring-1 ring-gray-300 rounded-md min-w-full p-2"
onChange={(e) => {
console.log(e.target.value);
}}
/>
);
};
Once done, it's time to import the <SearchInput/> component into page.tsx.
// src/app/search/page.tsx
import { SearchInput } from "@/app/search/client";
interface Props {
searchParams: {
query?: string;
};
}
export default function Page(props: Props) {
const { searchParams } = props;
const { query } = searchParams;
return (
<div>
<SearchInput />
<div>Query: {query}</div>
</div>
);
}
And here's the result, we successfully used onChange.

Connecting to Search Params
Actually, we can use the built-in searchParams feature from Next.js, the code would look like this:
// https://nextjs.org/learn/dashboard-app/adding-search-and-pagination#2-update-the-url-with-the-search-params
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}
}
However, I'm not the type of person who likes writing useSearchParams, usePathname, and useRouter repeatedly.
That's why we'll use next-query-params
Its usage is very similar to useState, we just need to create const [name, setName] = useQueryParam(), here's an example:
// https://www.npmjs.com/package/next-query-params#usage
import {useQueryParam, StringParam, withDefault} from 'use-query-params';
export default function IndexPage() {
const [name, setName] = useQueryParam('name', withDefault(StringParam, ''));
function onNameInputChange(event) {
setName(event.target.value);
}
return (
<p>My name is <input value={name} onChange={onNameInputChange} /></p>
);
}
Let's get started.
First, install the dependencies.
pnpm add next-query-params use-query-params
Then we create the file /src/app/providers.tsx.
By default, App Router uses server components. However, there are some components that must use client components.
This providers.tsx file is commonly used by several libraries like Chakra UI and Next UI. The goal is to keep the main layout.tsx file using server components, while only providers.tsx uses client components.
// src/app/providers.tsx
"use client";
import { Suspense } from "react";
import NextAdapterApp from "next-query-params/app";
import { QueryParamProvider } from "use-query-params";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<>
<Suspense>
<QueryParamProvider adapter={NextAdapterApp}>
{children}
</QueryParamProvider>
</Suspense>
</>
);
}
After that, it's time to import <Providers/> into /src/app/layout.tsx.
// src/app/layout.tsx
import { Providers } from "@/app/providers";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Finally, update the <SearchInput/> component we created earlier in the file /src/app/search/client.tsx
Add useQueryParam. Since we're using the name "query", it becomes useQueryParam("query").
StringParam will help automatically convert the value of query to a string. Besides string, there are also helpers for number (NumberParam) and array (ArrayParam).
"use client";
import { StringParam, useQueryParam } from "use-query-params";
export const SearchInput = () => {
const [query, setQuery] = useQueryParam("query", StringParam);
return (
<input
className="ring-1 ring-gray-300 rounded-md min-w-full p-2"
onChange={(e) => {
setQuery(e.target.value);
}}
/>
);
};
Done.

Every time we type a value in the input component, the URL will change to /search?query=hello. And if we delete it, it will be removed as well.
You can try it here seach-params-playground.vercel.app/search?query=laptop.
Polish With Debounce
Every time we type something in the input component, the search params will change instantly. Unfortunately, this has some negative effects.
Imagine a search component connected to an API. Every time the user types one letter, one fetch to the endpoint is made. So what happens if the user types 10 letters? There will be 10 fetch calls to the endpoint. This is not a good thing. Therefore, we need to wait for the user to finish typing their search, then we perform the fetch.
This "waiting" workflow is commonly called debounce.
To add it to the input component, let's use use-debounce (the quick way).
pnpm add use-debounce
Let's update the <SearchInput/> component using useDebouncedCallback.
"use client";
import { StringParam, useQueryParam } from "use-query-params";
import { useDebouncedCallback } from "use-debounce";
export const SearchInput = () => {
const [query, setQuery] = useQueryParam("query", StringParam);
const handleChange = useDebouncedCallback((value: string) => {
setQuery(value);
}, 500); // wait time duration
return (
<input
className="ring-1 ring-gray-300 rounded-md min-w-full p-2"
onChange={(e) => {
handleChange(e.target.value);
}}
/>
);
};
Done.
We don't directly run setQuery every time a change occurs, instead we wrap it in useDebouncedCallback and wait for 500 ms (0.5 seconds), then the search params will be updated.
Fetching Real Data
It wouldn't be complete if we didn't create a real implementation of this component.
Before that, add zod first.
pnpm add zod
Then, in the file /src/app/search/page.tsx change it to the following, add the getData function. We will use dummy data.
// /src/app/search/page.tsx
import { z } from "zod"
const getData = async (query: string | undefined) => {
const schema = z.object({
products: z.array(
z.object({
id: z.number(),
title: z.string(),
description: z.string(),
price: z.number(),
discountPercentage: z.number(),
rating: z.number(),
stock: z.number(),
brand: z.string(),
category: z.string(),
thumbnail: z.string(),
images: z.array(z.string()),
}),
),
total: z.number(),
skip: z.number(),
limit: z.number(),
});
const response = await fetch(
`https://dummyjson.com/products/search?q=${query}&limit=10`,
);
const data = await response.json();
return schema. Parse(data);
};
export default async function Page(props: Props) {
const { searchParams } = props;
const { query } = searchParams;
const data = await getData(query);
return (
<div>
<div className="mb-2">Query: {query}</div>
<SearchInput />
<div className="grid grid-cols-2 gap-4">
{data.products.map((product) => (
<div
key={product.id}
className="rounded-md border border-gray-300 p-4 my-4"
>
<h2 className="text-xl">{product.title}</h2>
<p className="text-gray-500">Category: {product.category}</p>
<img
className="rounded-md w-48 h-48 object-cover"
src={product.thumbnail}
alt={product.title}
/>
</div>
))}
</div>
</div>
);
}
Done.
You can try it at the following link seach-params-playground.vercel.app/search.
Conclusion
Since the emergence of App Router, many old paradigms had to be restructured, one of which is the separation of client components and server components.
It felt complicated at first. But as time goes on, I'm actually enjoying it more.
From initially being an App Router hater, I became an everyday App Router user.
For more about search params, you might be interested in reading about Search Params with nuqs in Next.js
Finished writing in Pelaihari, April 8, 2024 at 00:29 AM.
Finished reviewing at 00:47 AM.


