Building this site #6: Search with Next.js API routes

I thought implementing dark mode on this site was challenging, but putting together a proper search feature gives it a run for its money. There are quite a few moving parts, but I was most interested in an excuse to tinker with Next.js's built-in API routes. The gist of it is that one can very easily create API endpoints in a Next.js project by placing them in the folder pages/api.

In our case, a function in pages/api/search.js handles parsing an incoming request, using the query parameter to look up matching posts. For example, hitting the URL /api/search?q=react should give one an array of posts that contain "react." Here's what the implementation looks like:

// pages/api/search.js

export default function search(req, res) {
  if (!req.query.q || req.query.q.length === 0) {
    res.status(200).json([]) // Return an empty array
  }

  const posts = require("../../data/cache/posts.json")
  // Make query case-insensitive and with word boundaries
  const query = new RegExp("\\b" + req.query.q + "\\b", "i")

  const results = posts
    .filter(({ title, date, tags, description, content }) => {
      const result = title.match(query)
        || description?.match(query)
        || date.match(query)
        || tags.some((tag) => tag.label.match(query))
        || content.match(query)
        
      return result ? true : false
    })
    .map(({ title, description, date, slug }) => {
      return { title, description, date, slug }
    })
  
  res.status(200).json(results)
}

Note that the posts are sourced from a JSON file – more on that later. As for computing search results, we create a regular expression out of the submitted query at req.query.q, making it case-insensitive and wrapping it in word boundaries (so looking up "react" will ignore "reaction", etc.), and matching it against certain properties of a post: title, description, date, tags, and content. If any of these match, that post is included in the results.

Now any part of the site is able to use this endpoint to obtain a list of posts relevant to a given query. Before going any further, here's what I decided my search feature should be able to do:

  1. All search functionality is contained in its own Search page.
  2. That page should should contain a search bar and space for a variable number of search results to display. Each search result should point to its own page.
  3. A search will be performed only after hitting submit (not automatically with each new character), except when the search bar is cleared, in which case it will clear the results as well.
  4. When viewing a search result (i.e., in its own page), we should be able to navigate back to the search results page with the results from the last search preserved.
  5. Navigating to any other page (not one of the search results) will reset the Search page.

The Search page and SearchBar

First, I decided to put a search icon next to my theme switch on the top-right side of the site. That icon acts as a link, which points to /search. In pages/search.js, here's what the page looks like:

// pages/search.js

import { Spinner } from "react-bootstrap"
import { PageWrapper, PostList, SortButton, SearchBar } from "../components"
import { getPosts } from "../lib/content"
import { useEffect } from "react"
import { useOrderedPosts, useSearch } from "../lib/hooks"

export default function Search({ total }) {
  const { results, query, setQuery, searching } = useSearch()
  const { orderedPosts, order, setOrder, setPosts } = useOrderedPosts(results)

  const getResultText = (count) => {
    return `
      ${count} ${count === 1 ? "result" : "results"}
      ${query ? `for: '${query}'` : ""}
    `
  }
  
  useEffect(() => {
    setPosts(results)
  }, [results])

  return (
    <PageWrapper title="Search">
      <SearchBar 
        focus={query.length === 0}
        hint={`Search ${total} posts...`}
        onSearch={(query) => setQuery(query)}
        initialQuery={query}
      />
      <br />
      <PostList 
        heading={searching 
          ? (<Spinner animation="border" />)
          : getResultText(results.length)} 
        posts={orderedPosts}
        sideButton={(<SortButton 
          order={order} 
          setOrder={setOrder} 
          disable={orderedPosts.length === 0} 
        />)}
      />
    </PageWrapper>
  )
}

export async function getStaticProps() {
  const fs = require("fs")
  const posts = getPosts()
  fs.writeFileSync("data/cache/posts.json", JSON.stringify(posts))
  
  return { 
    props: { total: posts.length } 
  }
}

Quite a few things going on here. But first let's take a look at getStaticProps, which is where we cache all our post data into a static JSON file, as mentioned earlier, for our search endpoint to use later. As for the page component itself, the layout is pretty straightforward: a custom component SearchBar, containing search form functionality, sits on top of a PostList, which is pretty self-explanatory and by now has been reused in many places.

Here's what SearchBar looks like:

// components/SearchBar.js

import { Form, FormControl, Button } from "react-bootstrap"
import { useState, useEffect, useRef } from "react"
import { useTheme } from "../lib/hooks"

export default function SearchBar({ focus, hint, initialQuery, onSearch }) {
  const { theme } = useTheme()
  const form = useRef(null)
  const [query, setQuery] = useState("")

  useEffect(() => {
    if (focus) form.current.focus()
  }, [])

  useEffect(() => {
    // Must fire when query is empty but not on initial render,
    // so we also check if the initial search value has been changed.
    if (query.length === 0 && form.current.value !== initialQuery) {
      onSearch("")
    }
  }, [query])

  return (
    <Form 
      className="search-bar d-flex my-2"
      onSubmit={(e) => {
        e.preventDefault()
        onSearch(query)
      }}
    >
      <FormControl
        ref={form}
        className="mr-2"
        type="search"
        size="lg"
        placeholder={hint}
        aria-label="Search"
        onChange={(e) => setQuery(e.target.value)}
        defaultValue={initialQuery}
      />
      <Button 
        type="submit" 
        variant={theme}
        disabled={query.length === 0}
      >
        <i className="bi bi-search" />
      </Button>
    </Form>
  )
}

This is pretty straightforward as well (just a form and a button), except in two places: 1) we create a reference to the form using useRef and use a useEffect which fires on initial render uses that reference to focus on the form (so that opening an empty Search page automatically focuses the search form); and 2) a second useEffect calls the onSearch callback when the query is empty (since we want the results to clear when the search bar is cleared) and when the current value of the form is different from the given default value – this is to prevent it from firing on the initial render.

Communicating with the API

But the real heart of this search implementation is a custom hook called useSearch, which is referenced above by the Search page/component. The hook returns the properties results, query, setQuery, and searching which are all used by the page to update state in various places. For example if searching is true, the heading of PostList will be an animated spinner. Otherwise, the list of search results will display, and so on. Calling the function setQuery makes a new search request.

Here's what useSearch looks like:

// lib/hooks/useSearch.js

import { useState, useEffect } from "react"
import { useSearchState } from "./useSearchState"

export function useSearch() {
  const { searchState, setSearchState } = useSearchState()
  const [query, setQuery] = useState(searchState.query)
  const [results, setResults] = useState(searchState.results)
  const [searching, setSearching] = useState(false)

  const onSearched = (data) => {
    setResults(data)
    setSearching(false)
  }

  useEffect(() => {
    if (query !== searchState.query) {
      setSearching(true)
    }

    if (!query || query.length === 0) {
      onSearched([])
    } else {
      fetch(`api/search?q=${query}`)
        .then((res) => res.json())
        .then((data) => onSearched(data))
        .catch(() => onSearched([]))
    }
  }, [query])

  useEffect(() => {
    setSearchState({ query, results })
  }, [results])

  return { results, query, setQuery, searching }
}

The main responsibility of this custom hook is to communicate directly with the search endpoint, sending search requests and fetching the results. (If a given query is empty, it won't bother hitting the API, and will just automatically return an empty array.) But its other, just as important responsibility is preserving the search results when navigating away from the search page to view one of the results.

Preserving search state

To do the above, in useSearch we reference another custom hook, useSearchState, which is actually useContext underneath –

// lib/hooks/useSearchState.js

import { useContext } from "react"
import { SearchStateContext } from "../../components/SearchStateProvider"

export function useSearchState() {
  return useContext(SearchStateContext)
}

which means we're using React Context to preserve state globally. In this case, the implementation is rather simple. We just want to be able to use an initial state representing search data (in this case, an object containing a query and results properties, each of which is empty by default), and the ability to update that state each time after making a new search request. Here's what it looks like:

// components/SearchStateProvider.js

import { createContext, useState } from "react"

export const SearchStateContext = createContext()

export default function SearchStateProvider({ children }) {
  const initialState = { query: "", results: [] }
  const [searchState, setSearchState] = useState(initialState)

  return (
    <SearchStateContext.Provider value={{ searchState, setSearchState }}>
      {children}
    </SearchStateContext.Provider>
  )
}

This SearchStateProvider component wraps around the main app component, allowing us to share search state across pages, like so:

// pages/_app.js

import { SearchStateProvider } from "../components"

export default function MyApp({ Component, pageProps }) {
  return(
    <SearchStateProvider>
      <Component {...pageProps} />
    </SearchStateProvider>
  )
}

So, going back to useSearch, we are then able to populate the variables query and results initially with data from SearchStateProvider. In the second useEffect, each time results changes, we update the state by calling the provided setSearchState function with a new object containing the current query and results:

  useEffect(() => {
    setSearchState({ query, results })
  }, [results])

And that is a quick breakdown of all the moving parts of my search implementation. The search function itself works as swimmingly as I can make it; but my favorite part is being able to navigate to a specific search result and navigate back to the Search page with the results still intact.