I just recently upgraded my digital garden with search and, like adding feeds, the Next.js App Router made this feature extremely easy to accomplish. But more than a pleasant developer experience, I'm most excited by how simple it was to create a progressively enhanced search interface with React Server Components.
The foundation of the search component is statically-rendered, browser-native HTML that provides a fast, robust, and secure baseline search experience. If you're on a very slow connection, searching the site should be faster than waiting for the interactive JS to finish loading. But, once that JS does load, I can layer interactive affordances on top, like providing instant results as you type and improving navigation.
While this was ultimately a fairly simple exercise, I have a few reasons for sharing the process of developing this component. First, search is a very common pattern and the more documentation on how to build it, the better. Second, this example touches on many new features introduced to React and Next.js while still being simple enough to wrap your head around. Finally, I wanted to share how this framework enables developers to follow a path of progressive enhancement from the very beginning, instead of just shrugging off accessability until "some time later on."
Creating a progressively enhanced search component with Next.js App Router and React Server Components
The first thing I did was create the statically-rendered HTML foundation for this feature. I started by creating some data types, a search function, a
<Search /> component, and a new
This search function could do anything—maybe it searches by calling a service like Algolia, or maybe it directly queries a Postgres database. Since the function runs on the server, I'm not risking leaking credentials to the client.
After defining the Search component, I rendered it onto a
With just this, I already have a fully working search! This relies on the fundamental form functionality provided in every browser. On submission, the form redirects to the search page and provides the query as a URL search parameter. Since the
Enhancement #1: Instant Search
It's nice getting search results after hitting enter, but it's even nicer to see the results update live as you type.
Since this enhancement has client-side interactivity, I needed to add some asynchronous data fetching behavior and state management inside the component. The simplest way to add this dynamic functionality with React was to encapsulate it as a hook:
This hook declares the query and response as stateful values, returning them back to the component along with a function to update the query state. It also tracks the value of the previous query with a reference, which will persist the value between component rendering cycles. Finally, it defines a search function and runs it whenever the value of query changes. Finally, the search function runs are debounced to ensure we're not running it responsibly.
useEffect can be confusing, it's worth looking closer at how the dependency arrays ensure the search function is run as we expect. The
runSearch function is declared with
useCallback and a dependency array of
[query]. This means that only after the
query changes will
runSearch be reevaluated with the new query value. Likewise, the effect will only and always run the search function any time the function's value is reevaluated. This dependency chaining means, indirectly, that the effect will run the search function every time the
It's also worth noting that I'm using a different search function in the hook than the one defined in
src/data/search.ts and used by
src/app/search/page.tsx. That's because the code in the hook will only ever execute on the client. I always try to avoid making external calls from client code, which risks exposing any API keys or other potentially sensitive information to clients (or, at the very least, risks shipping broken code that cannot access necessary environment variables).
Instead, I took advantage of Route Handlers in Next.js 13 to create a new endpoint to mediate between the client component and the data logic.
Finally, I updated the Search component to use my new
Compared to the component I first created, this one:
"use client"at the top of the file, identifying it to Next as a client component.
- Assumes the initial data it receives is the same as the initial state of
- Uses the stateful values of
responseprovided by the hook instead of the ones directly passed to the component.
- Makes the search field a controlled component by locking its value to the stateful
queryvalue; to update the value, I added a function that updates the state whenever the field emits an
Enhancement #2: Navigation
Speaking of form submission: by relying on basic browser behavior, the form will automatically redirects to the
/search page upon submission. Without changing anything, this would run the query on the server and render a new page with the results.
But, since Next.js provides client-side routing, I wanted to make this interaction even smoother for users who've loaded JS. By handling the form's
onSubmit event, I upgraded the form behavior to prefer client-side routing when it's available.
It's helpful to have the query preserved in the navigation history, so that if I choose a result and then use the browser's "back" button, I'm returned to the same search I just preformed. But when using the enhanced instant search, the query isn't preserved in the history.
This was an easy fix. I added a line to the hook's search function to update the route after the search completes. While I was at it, I consolidated my
onSubmit handlers into the hook. Now my hook provides everything the enhanced component needs, without concerning the component with any underlying state management.
(I opted to update the search param during search, rather than on every change to the query value, to avoid adding unhelpful noise into a visitor's browser history.)
By the end of this process, I had only added around 250 lines of code across six files:
- A type declaration file (which could have been inlined elsewhere).
- A data handling file, to keep sensitive logic isolated from components.
- A hook that provides all the enhanced client-side search functionality (which, again, could have been inlined alongside the client component).
- A user-facing
/api/searchroute handler to allow my client-side functionality to safely call my data handling function.
Over half of this was in service of enhanced client-side functionality, which while totally optional, was very easy to include.
Progressive enhancement is a process, not only an outcome
In my experience, when a software company promises to improve the accessibility or compatibility of a feature at a later time, that promise almost never come true. It's understandable why that's the case. There's always new demands and higher priorities that are unforeseeable from the start.
That's why it's important for web developers to use the technologies and strategies that make progressive enhancement part of the development process from the outset. That's also why I'm so excited by the continued development of Next.js and React to make it easier than ever to develop and deploy progressively enhanced frontends.
With Next App Router and React Server Components, I had the framework to easily develop and render static HTML that quickly achieved fundamental functionality. That provided me the foundation to layer on richer functionality for an improved experience. Progressive enhancement was part of the development process from the get-go, leading to a component that works for everybody, all the time.