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:
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: "{query}"
</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: "{query}"
</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:
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:
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.
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.