Search functionality [Building Personal Blog Website Part 6]

Published on 12/5/2022, 12:00:00 AM

Now you’ll use the experience from displaying list of posts for specific tag to create a Search component. User will be able to search for any query and should get a list of posts that fulfill the query.

Start with creating new route pages/search/[query].js - it will be mostly based on [tag].js but with a twist! This time you will not use getStaticPaths as it is not possible to generate all queries that the user can put in the search bar. You’ll need to fetch results dynamically from the client.

And as you’ll be fetching posts dynamically you need to ensure the user is aware that something is loading. Usually there are two ways of indicating this to the user visually - spinners or progress bars and skeleton content. The first solution is easier, but does not prepare the user for what’s coming and also it has some effects on a performance metric called Content Layout Shift (you can read more about it HERE). The second solution requires a bit more work, but it’s worth it - you’ll need to prepare a blank template of the loaded content to indicate to the user that something is loading. The end result will look like this:

Blog-6-1.jpg

So before you’ll implement [query].js let’s just quickly prepare this skeleton Blog Post preview. Create a new component components/BlogPostPreviewSkeleton.jsx and put this code inside:

import React from "react";

const BlogPostPreviewSkeleton = () => {
  return (
    <div
      role="status"
      className="space-y-8 animate-pulse md:space-y-0 md:space-x-8"
    >
      <div className="flex flex-col justify-between max-w-sm rounded overflow-hidden shadow-lg">
        <div className="flex justify-center items-center w-full h-48 bg-gray-300 rounded sm:w-96 dark:bg-gray-700">
          <svg
            className="w-12 h-12 text-gray-200"
            xmlns="http://www.w3.org/2000/svg"
            aria-hidden="true"
            fill="currentColor"
            viewBox="0 0 640 512"
          >
            <path d="M480 80C480 35.82 515.8 0 560 0C604.2 0 640 35.82 640 80C640 124.2 604.2 160 560 160C515.8 160 480 124.2 480 80zM0 456.1C0 445.6 2.964 435.3 8.551 426.4L225.3 81.01C231.9 70.42 243.5 64 256 64C268.5 64 280.1 70.42 286.8 81.01L412.7 281.7L460.9 202.7C464.1 196.1 472.2 192 480 192C487.8 192 495 196.1 499.1 202.7L631.1 419.1C636.9 428.6 640 439.7 640 450.9C640 484.6 612.6 512 578.9 512H55.91C25.03 512 .0006 486.1 .0006 456.1L0 456.1z" />
          </svg>
        </div>
        <div className="px-6 py-4">
          <div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
          <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[480px] mb-2.5"></div>
          <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
          <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[440px] mb-2.5"></div>
          <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[460px] mb-2.5"></div>
          <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]"></div>
        </div>

        <div className="px-6 pt-4 pb-2">
          <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
        </div>
        <span className="sr-only">Loading...</span>
      </div>
    </div>
  );
};

export default BlogPostPreviewSkeleton;

I used the code from Flowbite, but adjusted it to my needs (so the layout looks kinda like BlogPostPreview component). As you can see there’s also a span for screen readers (it will be read instead of all the other content that is here).

The next step would be implementing actual Search Results page. In your [query].js put this code:

import { gql } from "@apollo/client";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import client from "../../apollo-client";
import BlogPostPreview from "../../components/BlogPostPreview";
import BlogPostPreviewSkeleton from "../../components/BlogPostPreviewSkeleton";

export default function SearchResults() {
  const router = useRouter();
  const { query } = router.query;
  const [searchResults, setSearchResults] = useState(null);

  useEffect(() => {
    const getSearchResults = async () => {
      const { data } = await client.query({
        query: gql`
          query Posts {
            posts(sort: "publishedAt:desc"
                filters: { content: { containsi: "${query}" } }) {
              data {
                attributes {
                  title
                  slug
                  tags {
                    data {
                      attributes {
                        tagId
                        name
                      }
                    }
                  }
                  publishedAt
                  excerpt
                  cover {
                    data {
                      attributes {
                        url
                      }
                    }
                  }
                }
              }
            }
          }
        `,
      });
      return setSearchResults(data.posts.data);
    };
    getSearchResults();
		return () => {
      setSearchResults(null);
    };
  }, [query]);

  const preparePostPreviews = () => {
    if (searchResults.length > 0) {
      return searchResults.map((post) => (
        <BlogPostPreview post={post} key={post.attributes.slug} />
      ));
    } else {
      return (
        <h4 className="font-mono text-black text-lg sm:col-span-2 lg:col-span-3 text-center">
          No results
        </h4>
      );
    }
  };

  return (
    <section className="my-8 mx-4">
      <h2 className="font-mono text-black text-xl md:text-4xl text-center mb-8">
        Search results for: &quot;{query}&quot;
      </h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 ">
        {searchResults ? (
          preparePostPreviews()
        ) : (
          <>
            <BlogPostPreviewSkeleton />
            <BlogPostPreviewSkeleton />
            <BlogPostPreviewSkeleton />
          </>
        )}
      </div>
    </section>
  );
}

Let’s go through this code together bit by bit.

const router = useRouter();
const { query } = router.query;
const [searchResults, setSearchResults] = useState(null);

First things to put in your newly created component are some useful declarations. router and query will be needed to properly extract search query from the URL. useState for searchResults is a internal component state keeping all the search results data inside. Initialize it with null as at the beginning you don’t have the results yet.

Now let’s look at the useEffect:

useEffect(() => {
    const getSearchResults = async () => {
      const { data } = await client.query({
        query: gql`
          query Posts {
            posts(sort: "publishedAt:desc"
                filters: { content: { containsi: "${query}" } }) {
              data {
                attributes {
                  title
                  slug
                  tags {
                    data {
                      attributes {
                        tagId
                        name
                      }
                    }
                  }
                  publishedAt
                  excerpt
                  cover {
                    data {
                      attributes {
                        url
                      }
                    }
                  }
                }
              }
            }
          }
        `,
      });
      return setSearchResults(data.posts.data);
    };
    getSearchResults();
		return () => {
      setSearchResults(null);
    };
  }, [query]);

At the beginning you are fetching the data from GraphQL API. It’s a rather standard call, but one thing is worth noting - the containsi filter. containsi filter matches the query against the selected field but it does it case insensitive. We put this GraphQL call inside a local async function to easily call it in the body of the useEffect.

There’s also setSearchResult(null) on cleanup - when the user performs another search while on the search results page, the component will first unmount and clear the previous results and then mount again with new data. And of course in the dependency array you have query - you want to reload the data as soon as the query changes.

Later in the file you have this helper function:

const preparePostPreviews = () => {
    if (searchResults.length > 0) {
      return searchResults.map((post) => (
        <BlogPostPreview post={post} key={post.attributes.slug} />
      ));
    } else {
      return (
        <h4 className="font-mono text-black text-lg sm:col-span-2 lg:col-span-3 text-center">
          No results
        </h4>
      );
    }
  };

When there are search results you want to show a BlogPostPreview for every one of those. But if the search results array is empty you want to let the user know, that there was no results.

And finally a component itself:

return (
    <section className="my-8 mx-4">
      <h2 className="font-mono text-black text-xl md:text-4xl text-center mb-8">
        Search results for: &quot;{query}&quot;
      </h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 ">
        {searchResults ? (
          preparePostPreviews()
        ) : (
          <>
            <BlogPostPreviewSkeleton />
            <BlogPostPreviewSkeleton />
            <BlogPostPreviewSkeleton />
          </>
        )}
      </div>
    </section>
  );

Until the GraphQL query is finished you render the skeleton content, but as soon as the data is through - you use preparePostPreviews function to display the content properly.

Before you hook everything up you need to do a small adjustment in BlogPostPreview. You’ll display publishDate of every post. In your code add this snippet just over the div containing the title:

<h6 className="font-mono text-black text-xs mb-2">
  {new Date(post.attributes.publishedAt).toLocaleString()}
</h6>
<hr className="mb-2" />

Now you need to make sure that publishedAt is always fetched. Go through your GraphQL queries and add publishedAt as an additional fragment of data to be fetched. For example in [slug].js:

data {
  attributes {
    title
    slug
    content
    publishedAt
    cover {
      data {
        attributes {
          url
        }
      }
    }

When this is done you can test out your newly created Search Results page. Go to localhost:3000/search/YOUR-QUERY (replace YOUR-QUERY with some text that occurs in one of your posts). After a brief moment you should see the results:

Blog-6-2.jpg

You’re not finished though! One more thing to do! Let’s create a search bar on the navigation bar.

In components/Navbar.jsx add another item into the flex container (it should be the last one):

<form onSubmit={handleSearch}>
  <div className="flex">
    <label
      htmlFor="location-search"
      className="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-gray-300"
    >
      Your Email
    </label>
    <div className="flex w-full">
      <input
        type="search"
        id='location-search"'
        className="rounded-l-lg rounded-r-none block p-2.5 z-20 text-sm text-gray-900 bg-gray-50 border-l-gray-50 border-l-2 border-r-0 border border-gray-300 focus:ring-blue-500 focus:border-blue-50"
        placeholder="Search..."
        required=""
      />
      <button
        type="submit"
        className="p-2.5 text-sm font-medium text-gray-400 bg-gray-50 rounded-r-lg border border-gray-300 focus:ring-4 focus:border-blue-500 focus:ring-blue-500"
      >
        <svg
          aria-hidden="true"
          className="w-5 h-5"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
          xmlns="http://www.w3.org/2000/svg"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth="2"
            d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
          ></path>
        </svg>
        <span className="sr-only">Search</span>
      </button>
    </div>
  </div>
</form>

handleSearch method will be implemented in a minute. The navigation bar should look like this now:

Blog-6-3.jpg

Now whenever user uses the search button or press Enter in this input they should be redirected to /search/SEARCH-QUERY. Let’s implement this.

const Navbar = () => {
  const router = useRouter();

  const handleSearch = (e) => {
    e.preventDefault();
    router.push(`/search/${e.target[0].value}`);
  };
// rest of the code...
}

First you need to use preventDefault because when form is submitted the site is reloaded and you don’t want it here. Second you need to use next/router to redirect user. It’s that simple. Rebuild your app, start your dev server and try searching for something with this new search bar.

Blog-6-4.jpg

And that’s it - now publish your changes on Netlify (you know how to do this already!). In the next part of this guide you’ll start to provide SEO for your blog posts.

Get in touch

You can find me and contact me the following ways: