Next.js 13 search with React Server Components

Learn how to implement search with debounce using React Server Components and the app router in Next.js 13.
July 17, 2023Next.js
6 min read

In this article, we'll use Next.js 13 (with the app router) to implement server-side search with React Server Components. We'll create a page using a React Server Component. This page will use a client component called Search that runs in the browser, not on the server.

We will use Tailwind CSS to add some basic styling to our app.

The page component

Let's start by creating a new page. We'll create this new page in an /app/page.tsx file. The component that we will define in this file will be a React Server Component.

import Search from '@/components/search';
import { Product } from '@/lib/types';

export default async function Home({
  params,
  searchParams: { search },
}: {
  params: {},
  searchParams: { search: string },
}) {
  const result = await fetch(
    `${process.env.NEXT_PUBLIC_HOST}/api/search/${search}`,
    {
      method: 'GET',
    }
  );

  const products: Product[] = await result.json();

  const productsData = products.length ? (
    <div className="space-y-6">
      <h2 className="text-lg font-semibold">Search Results</h2>
      {products.map(({ id, name }) => (
        <article key={id}>
          <h3>{name}</h3>
        </article>
      ))}
    </div>
  ) : search ? (
    <p className="bg-gray-100 rounded-lg text-gray-500 p-4 text-center">
      No results found.
    </p>
  ) : (
    <p>Search for a product.</p>
  );

  return (
    <main className="container mx-auto space-y-6 my-6">
      <h1 className="text-2xl font-bold">Search for products</h1>

      <Search />

      {products.length && <p>{products.length} results.</p>}

      <section>{productsData}</section>
    </main>
  );
}

Accessing searchParams from the URL

We want the Home page to access searchParams from the page URL, particularly a search parameter that will be set by the Search component in an upcoming step. We can access the searchParams via the function arguments of the component for this page.

The page's fetch request

Next, we set up the fetch request to a Next.js API route that we will create in the next step. If the response returns products, we show how many results were returned, and we display the products. If not, we display an appropriate message.

For the URL in the fetch request to work, we'll need to add a NEXT_PUBLIC_HOST environment variable. This variable will contain the host address of our app. We can create a new .env file in the root of our project to add this variable.

NEXT_PUBLIC_HOST=http://localhost:3000

The API route

To create the API route, create a route.ts file within the following folder structure /app/api/search/[search]/route.ts.

This API route will receive a GET request with a dynamic search param that we defined using the file-based routing path above.

To keep it simple, a hard-coded JSON file is used for the list of products rather than a database. We filter the list of products, looking for matching characters in the product name. Finally, we return an array of matching results.

import { NextRequest, NextResponse } from 'next/server';
import products from '@/data/products.json';

export async function GET(
  req: NextRequest,
  { params: { search } }: { params: { search: string } }
) {
  try {
    const results = products.filter((product) =>
      product.name.toLowerCase().includes(search.toLowerCase())
    );

    return NextResponse.json(results);
  } catch (error) {
    const message =
      error instanceof Error ? error.message : 'An error occurred';
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

The Search component

Next, let's create our Search component in a new /components/search.tsx file. This component will contain an input text box. Whenever the text inside this text box is updated, the URL will be updated with a search query parameter attached to the end of the URL. This query parameter will contain the search term used by the user.

'use client';

import { useRouter } from 'next/navigation';
import { ChangeEvent } from 'react';

export default function Search() {
  const router = useRouter();

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.value.length) {
      router.push(`/?search=${e.target.value}`);
    } else {
      router.push(`/`);
    }
  };

  return (
    <>
      <input
        placeholder="Search products..."
        type="search"
        onChange={onChange}
        className="p-2 border border-gray-300 rounded-lg"
      />
    </>
  );
}

Before updating the search query parameter in the URL, we check if a search term is actually present. If not, it might be that the user clicked the x in the text box to delete the search term, or used the Backspace key to clear the search box. In that case, we reset the URL, removing the search query parameter.

One serious limitation of the Search client component above is that it updates the URL on every keystroke. This can become a performance bottleneck since the search API call is triggered every time the URL is updated.

We can improve performance by adding a debounce function. We'll set the debounce function to only update the URL in intervals of 500 milliseconds. This results in a small delay in the appearance of search results for users as they type the search term, but it's an important performance improvement for our app.

We'll use the lodash debounce function to achieve this. First, install lodash and its corresponding types.

yarn add lodash
yarn add @types/lodash

Now, let's update the Search component to make use of the debounce function.

'use client';

import debounce from 'lodash/debounce';
import { useRouter } from 'next/navigation';
import { ChangeEvent, useCallback, useEffect } from 'react';

export default function Search() {
  const router = useRouter();

  const onChange = useCallback(
    debounce((e: ChangeEvent<HTMLInputElement>) => {
      if (e.target.value.length) {
        router.push(`/?search=${e.target.value}`);
      } else {
        router.push(`/`);
      }
    }, 500),
    [] // function will only be created once (initially)
  );

  return (
    <>
      <input
        placeholder="Search products..."
        type="search"
        onChange={onChange}
        className="p-2 border border-gray-300 rounded-lg"
      />
    </>
  );
}

We wrapped the debounce function with useCallback to cache it between re-renders. This means that it will only be created once, during the initial component render, and not re-created on every component render. Re-creating the debounce function on every component render is not necessary and can become computationally expensive, so it's best to avoid it.

When the Search component unmounts, the search request could be still in progress. This could result in unexpected behavior. We can use the useEffect hook to cancel the debounced function so that we avoid unexpected results. The addition of the debounce function now gives onChange a cancel method that we can use for this.

useEffect(() => {
  return () => {
    onChange.cancel();
  };
}, [onChange]);

We can now try running our project. Let's use "la" as a search term. We should get back two products in our search results: "Laptop" and "PlayStation".

Access the public GitHub repository for this article. Run it locally and feel free to experiment with it. This will help solidify the concepts covered in this article.

New
Be React Ready

Learn modern React with TypeScript.

Learn modern React development with TypeScript from my books React Ready and React Router Ready.