Building a Search Component in Next.js App Router

Building a Search Component in Next.js App Router

M. Zakyuddin Munziri

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.

More Articles

I Stopped Digging Through Logs

I Stopped Digging Through Logs

Debugging changed when I stopped reading logs manually and started using AI agents to correlate errors across observability data - faster root cause, fewer dead ends.

Speed Was Never the Hard Part in CI CD

Speed Was Never the Hard Part in CI CD

Fast pipelines don't eliminate shipping fear. Confidence comes from safe rollbacks, feature flags, and systems that behave predictably when things go wrong.