SimpleNext.js

How to implement a search functionnality in a Next.js app

Cover Image for How to implement a search functionnality in a Next.js app
Marouane Reda
Marouane Reda

Implementing a search functionality in a Next.js app should take into account the structure of our app, the nature of its data, … But if you need a general idea of how it could be done, this article shows how it was implemented in the current site you are on right now.

In our case, we will create a cache containing the title and the id of our articles ( id will let us recreate the link to our articles). We will then use an API to search into our cache for the values that are in our search component.

Creating the cache

The cache file will be a file containing a posts array, each element of the array will be an object containing the id of the article and its title. As we will create our search component for a static site, generated at build time, the most direct way is to create a script that also generates our cache file at build time.

In order to do this, we will modify the next.config.js file as follows :

module.exports = {
    webpack: (config, { isServer }) => {
      if (isServer) {
        require('./scripts/cache')
      }
  
      return config
    },
  }

this will execute the script ‘./scripts/cache.js’ at build time.

let’s create our cache.js script. for this, you must consider that our articles are under the ‘_posts’ directory and are markdown files, which means they have a ‘.md’ extension ( as shown in the picture ) :

https://firebasestorage.googleapis.com/v0/b/kmx1-16598.appspot.com/o/blog%2FCapture%20d%E2%80%99e%CC%81cran%202021-08-17%20a%CC%80%2021.58.08.webp?alt=media&token=fd870356-6741-4f55-851d-3381bf335d3b

Let’s see our script :

const fs = require('fs')
const path = require('path')
const matter = require('gray-matter')

function getPosts() {
const postsDirectory = path.join(process.cwd(), '_posts' ) //retrieving the posts directory path
const fileNames = fs.readdirSync(postsDirectory) // getting the names of the files, with the .md extension
const posts = fileNames.map(fileName => {

        const id = fileName.replace(/\.md$/, '') //getting rid of the .md extension
        const fullPath = path.join(postsDirectory, fileName) //creating the full path of the file
const fileContents = fs.readFileSync(fullPath, 'utf8') //getting the contents of the file
const matterResult = matter (fileContents)
return {
id,
title: matterResult.data.title   // readinf the file and retrieving its id and title from the markdown
}
})
return JSON.stringify(posts)
}
const fileContents = `export const posts = ${getPosts()}` // here we created the contents of the cache file

try {

fs.readdirSync('cache')
}
catch (e) {
fs.mkdirSync('cache')
}
// if cache directory exists, ok else create it
   

fs.writeFile('cache/data.js', fileContents, function (err) { // writing to the cache/data.js file
if (err) return console. log(err);
console.log("Posts cached.");

})

the comments are self-explanatory, of course you must adapt the script to you app structure.

here is the final result of the cache/data.js file in our case :

export const posts = [{"id":"customize-ant","title":"How to customize Ant design theme for Next.js"},{"id":"debug-vscode","title":"The simple guide to debug Next.js apps in VSCode"},{"id":"firebase-auth","title":"How to use Firebase authentification with Next.js"},{"id":"google-analytics-next","title":"How to add Google Analytics to your Next.js project"},{"id":"next-amp","title":"How to create AMP pages with Next.js"},{"id":"next-antd","title":"How to use antdesign with Next.js"},{"id":"next-api-rest","title":"How to create a REST API with Next.js"},{"id":"next-bootstrap","title":"How to use Bootstrap 5 with Next.js"},{"id":"next-chakra","title":"How to use Chakra-UI, Choc-UI and Chakra templates with Next.js"},{"id":"next-fetch","title":"How to fetch external data in Next.js"},{"id":"next-heroku","title":"Deploy your Next.js app on Heroku with ease"},{"id":"next-link","title":"Navigation in Next.js with the Link component"},{"id":"next-mongodb","title":"how to use Mongodb in your Next.js project"},{"id":"next-mysql","title":"How to use MySQL database in Next.js apps"},{"id":"next-rich-editor-quill","title":"Use Quill as a rich text editor in next.js"},{"id":"next-routing","title":"How to implement dynamic routing in Next.js"},{"id":"next-seo","title":"How to optimize SEO with Next.js"},{"id":"next-styled-components","title":"How to use styled components in Next.js apps"},{"id":"next-swr","title":"Simple data fetching with SWR in Next.js"},{"id":"optimize","title":"How to optimize your Next.js app build"},{"id":"react-quill","title":"The comprehensive guide to react-quill (for React and Next.js)"},{"id":"tailwind-next","title":"How to use Tailwind in your Next.js project"}]

What we have to do next is create an api to search into this cache

Create the search api

We will use the buit-in api mechanism in Next.js to create our search API. Under /pages/api, we create the search.js file :

const posts =  require('../../cache/data').posts 

export default (req, res) => {
  const results = req.query.q ?
    posts.filter(post => post.title.toLowerCase().includes(req.query.q.toLowerCase())) : []
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({ results }))
}

when we receive a search query in the API endpoint, we simply search this query in the cache and return the results in a JSON format.

We only need now to create our search componenent.

Create the search component

Here is the code of the search component :

import { useCallback, useRef, useState } from 'react'
import Link from 'next/link'
import styles from './search.module.css'

export default function Search() {

  const searchRef = useRef(null)
  const [query, setQuery] = useState('')
  const [active, setActive] = useState(false)
  const [results, setResults] = useState([])

  const searchEndpoint = (query) => `/api/search?q=${query}`

  const onChange = useCallback((event) => {
    const query = event.target.value;
    setQuery(query)
    if (query.length) {
      fetch(searchEndpoint(query))
        .then(res => res.json())
        .then(res => {
          setResults(res.results)
        })
    } else {
      setResults([])
    }
  }, [])

  const onFocus = useCallback(() => {
    setActive(true)
    window.addEventListener('click', onClick)
  }, [])

  const onClick = useCallback((event) => {
    if (searchRef.current && !searchRef.current.contains(event.target)) {
      setActive(false)
      window.removeEventListener('click', onClick)
    }
  }, [])

  return (
    <div
      className={styles.container}
      ref={searchRef}
    >
      <input
        className={styles.search}
        onChange={onChange}
        onFocus={onFocus}
        placeholder='Search posts'
        type='text'
        value={query}
      />
      { active && results.length > 0 && (
        <ul className={styles.results}>
          {results.map(({ id, title }) => (
            <li className={styles.result} key={id}>
              <Link href="/posts/[id]" as={`/posts/${id}`}>
                <a>{title}</a>
              </Link>
            </li>
          ))}
        </ul>
      ) }
    </div>
  )
}

On each change in the input, we send a request to the search api, the the results are formatted styled and presented under the input box :

https://firebasestorage.googleapis.com/v0/b/kmx1-16598.appspot.com/o/blog%2FCapture%20d%E2%80%99e%CC%81cran%202021-08-17%20a%CC%80%2022.19.28.webp?alt=media&token=e2b4d8db-c694-4fd2-b864-c58ce6f8e8e8

Conclusion

We have seen how we implemented the search functionality in our site, but the details should be adapted to the structure and specifies of your app.