Next.js 13 search with React Server Components
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.