Progressively Enhanced Search with Next.js 13 and React Server Components
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 /search
page:
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 /search
page:
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 SearchPage
is server-rendered, it runs the search and passes the results down to the component without a lick of JavaScript. As a bonus, the search field will be pre-populated with the query string if we refresh the page, or navigate forward or back later.
Now, with this rock-solid foundation in place, I started to progressively enhance the search experience with JavaScript-driven interactions to improve its responsiveness and utility.
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.
Since 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 query
changes.
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 useSearch
hook.
Compared to the component I first created, this one:
- Declares
"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
useSearch
. - Uses the stateful values of
query
andresponse
provided 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
query
value; to update the value, I added a function that updates the state whenever the field emits anonChange
event.
The Search component will still work without JavaScript, but now when JavaScript is available it will be able to return results immediately. Of course, this behavior could be further customized. It could instead show search suggestions for a type-ahead experience, or prefetch result data without rendering it until the form is submitted.
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 onChange
and 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.)
Wrapping Up
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 server component that works without any JavaScript enhancement, which was then upgraded to a client component when enhancements were layered on.
- A hook that provides all the enhanced client-side search functionality (which, again, could have been inlined alongside the client component).
- A user-facing
/search
page to render the component and load results, which also statically generates results pages for users without JavaScript. - A
/api/search
route 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.