Introduction
Websites can be information-heavy and tedious to navigate. Users may not always want to stay on your site browsing their day away, have short attention spans, or have very little patience. Many visitors come to a site seeking specific things, and if you can't provide that for them in the first few minutes they land on your site, they will leave it without a second thought. This is why search is important to implement.
This tutorial will break down how to implement client-based search on static sites using Strapi 5, Next.js, Fusejs, and Cloudflare.
Alternative Search Methods
There are various ways you can search for content on Strapi itself or on a frontend and consume data from it. You can search through content using its REST, GraphQL APIs with the filters, Document Service API in the backend with the filters as well. You can choose to install search plugins like this Fuzzy Search plugin on Strapi to enable search. A popular means of search others opt for is using search services and engines like Algolia, Meilisearch, etc.
What are we Building?
We will build, for example, a digital marketing agency website.
The package used for search on the client is Fuse.js. The project will be built with Next.js 15, and it will be a static site as the content it holds rarely changes. It will be deployed on Cloudflare to illustrate how changes in the content on Strapi can reflect on the site, its search data set, and the search index.
Below is a preview of the final feature, which you can try for yourself at this link.
Prerequisites
To build this project, you will need:
- Node.js(v20.15.1)
- Turbo(v2.1.2)
- Yarn(v4.2.2)
- a Cloudflare account - this is optional and serves to illustrate how the search index could be updated when Strapi content changes
Set Up The Project Monorepo
Since the project contains a separate Strapi backend and a Next.js frontend, it makes sense to create a monorepo to run them both at the same time. Turborepo is used to set this up.
Make the monorepo with the following commands on your terminal:
mkdir -p search/apps
cd search
Both the frontend and the backend are contained within the apps
folder.
Next, initialize a workspace using yarn:
yarn init -w
The turbo tasks that will run both the frontend and backend are configured using the turbo.json
file. Create this file using the command below:
touch turbo.json
Add these tasks to the turbo.json
file:
{
"$schema": "https://turborepo.org/schema.json",
"tasks": {
"develop": {
"cache": false
},
"dev": {
"cache": false,
"dependsOn": ["develop"]
},
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"generate-search-assets": {
"cache": false
}
}
}
The turbo configuration contains four tasks:
-
develop
which runs Strapi'sstrapi develop
-
dev
which runsnext dev
-
build
which runsstrapi build
andnext build
-
generate-search-assets
, which is added to the frontend later in this tutorial and generates the search assets required for client-based search.
The dev
task depends on the develop
task to ensure that the Strapi backend is running when the frontend tries to consume data from it. No outputs are cached for the development and search asset tasks.
To the scripts
and workspaces
sections of the package.json
file, add the following code:
{
"name": "search",
"packageManager": "yarn@4.2.2",
"private": true,
"workspaces": ["apps/*"],
"scripts": {
"dev": "turbo run dev --parallel",
"generate-search-assets": "turbo run generate-search-assets"
}
}
This package.json
file above describes the monorepo and its configuration settings. The newly added workspaces
property defines the file patterns used to identify the monorepo apps' locations. Without this, turbo won't be able to identify what tasks to run. The new scripts, dev
and generate-search-assets
, make it easier to run tasks that are executed frequently. The --parallel
flag in turbo run dev --parallel
ensures that both the frontend and backend are run simultaneously.
Create a Strapi 5 Application
The Strapi app is called dma-backend
. We will be making use a Strapi 5 applicaton. Create it using the command below inside your search
directory:
npx create-strapi-app@latest apps/dma-backend --no-run --ts --use-yarn --install --skip-cloud --no-example --no-git-init
When prompted to select a database, pick sqlite
. Once this command completes running, the Strapi app will be installed within the apps/dma-backend
folder.
Start the created Strapi app inside the dma-backend
directory with the command below:
turbo develop
When Strapi launches on your browser, create an administration account. Once it is created and you are routed to the admin panel, begin creating the content types as outlined in the next step.
Create Strapi Content Types
To illustrate how this type of search can work across multiple content types and fields, the app will have three Strapi content types:
- Service category
- Service
- Showcase
1. Service Category Collection Type
A service category groups related services. For example, a digital marketing category contains search engine optimization and influencer marketing services. These are the settings to use when creating it.
Field | Value |
---|---|
Display Name | Category |
API ID (Singular) | category |
API ID (Plural) | categories |
Type | Collection Type |
Draft & publish | false |
These are its fields and the settings to use to create them.
Field name | Type |
---|---|
name |
Text (Short text) |
description |
Rich text (Markdown) |
This is what it will look like on the admin panel (the services
relation will be added in the next section).
2. Service Collection Type
A service is the work the agency can provide to its clients. As in the example above, influencer marketing is a service. Here is the model of the Service collection type.
Field | Value |
---|---|
Display Name | Service |
API ID (Singular) | service |
API ID (Plural) | services |
Type | Collection Type |
Draft & publish | false |
Here are its fields and their settings.
Field name | Type | Other details |
---|---|---|
name |
Text (Short text) | |
tagline |
Text (Short text) | |
description |
Rich text (Markdown) | |
cover_image |
Media | |
category |
Relation with Category | Category has many services |
The service content type looks like this (the showcases
relation will be added in the next segment).
This is its category
relation.
3. Showcase Collection Type
A showcase is an example of the work that the agency has done that demonstrates the service it is trying to advertise. Create it using the following model:
Field | Value |
---|---|
Display Name | Showcase |
API ID (Singular) | showcase |
API ID (Plural) | showcases |
Type | Collection Type |
Draft & publish | false |
These are its fields and their settings.
Field name | Type | Other details |
---|---|---|
name |
Text (Short text) | |
url |
Text (Short text) | |
description |
Rich text (Markdown) | |
cover_image |
Media | |
service |
Relation with Service | Service has many showcases |
Here is what it looks like on the admin panel.
This is its service
relation.
Once you're done, add some dummy data to search through. If you'd like, you could use the data used in this project found here.
Make API Endpoints Public
From the admin panel, under Settings > Users and permission plugin > Roles > Public, ensure that the find
and findOne
routes of all the content types above are checked off. Then click Save to make sure they are publicly accessible.
Allow Endpoint for find
and findOne
for Category
Allow Endpoint for find
and findOne
for Service
Allow Endpoint for find
and findOne
for Showcase
Building the Frontend
The frontend is built with Next.js 15. To create it, run the following command:
cd apps && \
npx create-next-app@latest dma-frontend --no-src-dir --no-import-alias --no-turbopack --ts --tailwind --eslint --app --use-yarn && \
cd ..
Since the main aim of this project is to illustrate search, how the frontend is built won't be covered in great detail. This is a short breakdown of the pages, actions, utilities, and components added.
Pages
Page | Purpose | Path | Other details |
---|---|---|---|
Homepage | This is the home page | apps/dma-frontend/app/page.tsx | Only mentioning it here so that you change its contents to what is linked |
Service categories | Lists a service category and the services available under it | apps/dma-frontend/app/categories/[id]/page.tsx | |
Services | Shows the service description and lists showcases under that service | apps/dma-frontend/app/services/[id]/page.tsx | |
Showcases | Describes a showcase and links to it | apps/dma-frontend/app/showcases/[id]/page.tsx |
The Home Page:
The Service Category Page
The Service Page
This Showcase Page
Components
Component | Purpose | Path |
---|---|---|
Category card | Card that lists service category details | apps/dma-frontend/app/ui/category.tsx |
Service card | Card that lists service details | apps/dma-frontend/app/ui/service.tsx |
Showcase card | Card that lists showcase details | apps/dma-frontend/app/ui/showcase.tsx |
Header | Used as a header for pages | apps/dma-frontend/app/ui/header.tsx |
Actions
Action | Purpose | Path |
---|---|---|
Categories actions | Fetches service category data | apps/dma-frontend/app/actions/categories.ts |
Services actions | Fetches service data | apps/dma-frontend/app/actions/services.ts |
Showcases actions | Fetches showcase data | apps/dma-frontend/app/actions/showcases.ts |
Utilies and Definitions
Utility/definition | Purpose | Path |
---|---|---|
Content type definitions | Strapi content types | apps/dma-frontend/app/lib/definitions/content-types.ts |
Request definitions | Request types | apps/dma-frontend/app/lib/definitions/request.ts |
Request utilities | For making requests to Strapi | apps/dma-frontend/app/lib/request.ts |
Fuse.js Search Implementation: Building the Search Feature
Now, to the focus of this whole article. Begin by adding Fuse.js to the frontend.
yarn workspace dma-frontend add fuse.js
Next, create a script to download search data from Strapi and build an index from it. This script will also pull images from Strapi so that all the site assets are static.
mkdir -p apps/dma-frontend/strapi apps/dma-frontend/public/uploads apps/dma-frontend/lib/data && \
touch apps/dma-frontend/strapi/gen-search-assets.js
The above command creates three folders:
-
apps/dma-frontend/strapi
: contains the script that generates the search list and search index -
apps/dma-frontend/public/uploads
: holds all the images pulled from Strapi -
apps/dma-frontend/lib/data
: where the generated search list and the search index are placed
The touch apps/dma-frontend/strapi/gen-search-assets.js
command creates the script file that generates the search index and search list.
To the apps/dma-frontend/strapi/gen-search-assets.js
file, add the following code:
const qs = require("qs");
const Fuse = require("fuse.js");
const fs = require("fs");
const path = require("path");
const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
/*
* Downloads images from Strapi to the apps/dma-frontend/public folder
* as the site will be static and for the purposes of
* this tutorial, Strapi won't be deployed.
*
*/
async function saveImages(formats) {
const saveImage = async (imageUrl) => {
const pathFolders = imageUrl.split("/");
const imagePath = path.join(
path.resolve(__dirname, "../public"),
...pathFolders,
);
try {
const response = await fetch(`${strapiUrl}${imageUrl}`);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
fs.writeFileSync(imagePath, buffer);
console.log(`Image successfully saved to ${imagePath}`);
} catch (error) {
console.error(`Error downloading the image: ${error.message}`);
}
};
for (let size of ["thumbnail", "small", "medium", "large"]) {
saveImage(formats[size].url);
}
}
/*
* Fetches data from Strapi, formats it using Fuse.js,
* and creates search list and a search index. Saves
* both to files within apps/dma-frontend/app/lib/data.
*/
async function generateIndex() {
// Strapi query to populate services, showcases, and
// their images within the category data
const query = qs.stringify(
{
populate: {
services: {
populate: {
cover_image: true,
showcases: {
populate: {
cover_image: true,
},
},
},
},
},
},
{
encodeValuesOnly: true,
},
);
const resp = await fetch(`${strapiUrl}/api/categories?${query}`, {
method: "GET",
});
if (!resp.ok) {
const err = await resp.text();
try {
const errResp = JSON.parse(err);
console.log(errResp);
} catch (err) {
console.log(`There was a problem fetching data from Strapi: ${err}`);
}
} else {
let indexData = [];
let respData = [];
const body = await resp.json();
if (body?.error) {
console.log(
`There was a problem fetching data from Strapi: ${body.error}`,
);
return;
} else {
respData = body?.data || body;
}
// The search index data is created here
respData.forEach((cat) => {
if (cat["services"]) {
cat["services"].forEach((service) => {
if (service["showcases"]) {
service["showcases"].forEach((showcase) => {
saveImages(showcase.cover_image.formats);
showcase["type"] = "Showcases";
showcase["thumbnail"] =
showcase.cover_image.formats.thumbnail.url;
for (let key of [
"id",
"cover_image",
"createdAt",
"updatedAt",
"publishedAt",
]) {
delete showcase[key];
}
indexData.push(showcase);
});
}
saveImages(service.cover_image.formats);
service["thumbnail"] = service.cover_image.formats.thumbnail.url;
service["type"] = "Services";
for (let key of [
"showcases",
"cover_image",
"id",
"createdAt",
"updatedAt",
"publishedAt",
]) {
delete service[key];
}
indexData.push(service);
});
}
for (let key of [
"services",
"id",
"createdAt",
"updatedAt",
"publishedAt",
]) {
delete cat[key];
}
cat["type"] = "Categories";
indexData.push(cat);
});
// The search index is pre-generated here
const fuseIndex = Fuse.createIndex(
["name", "description", "link", "type"],
indexData,
);
// The search list and search index are written
// to apps/dma-frontend/app/lib/data here
const writeToFile = (fileName, fileData) => {
const fpath = path.join(
path.resolve(__dirname, "../app/lib/data"),
`${fileName}.json`,
);
fs.writeFile(fpath, JSON.stringify(fileData), (err) => {
if (err) {
console.error(err);
} else {
console.log(`Search data file successfully written to ${fpath}`);
}
});
};
writeToFile("search_data", indexData);
writeToFile("search_index", fuseIndex.toJSON());
}
}
generateIndex();
The code above does two things.
- It pulls the Categories, Services, and Showcases collections' data and creates two files: the search list and an optional serialized search index for faster instantiation of FuseJs. Both these files are placed in the
apps/dma-frontend/app/lib/data
folder. - The second thing it does is download all the images for the Categories, Services, and Showcases collections and places them in the
apps/dma-frontend/public
folder. This is mainly because the Strapi app in this tutorial is not deployed, only the frontend gets deployed. So the images must be bundled with the app. If you have your Strapi app deployed, you can comment on the image downloads.
In the apps/dma-frontend/package.json
file, add this to the scripts
object:
"scripts": {
...
"generate-search-assets": "node strapi/gen-search-assets.js"
}
generate-search-assets
runs the apps/dma-frontend/strapi/gen-search-assets.js
script that generates the search list and search index. It is added here to make it easier to run the script from the monorepo root.
Now you can generate the search assets with it (make sure Strapi is running on a separate tab with turbo develop
):
turbo generate-search-assets
Add the search page:
touch apps/dma-frontend/app/search/page.tsx
To this file, add:
"use client";
import Fuse, { FuseResult } from "fuse.js";
import searchData from "@/app/lib/data/search_data.json";
import searchIndexData from "@/app/lib/data/search_index.json";
import { ChangeEvent, useMemo, useState } from "react";
import { SearchItem } from "@/app/lib/definitions/search";
import Image from "next/image";
import Link from "next/link";
function Search() {
const searchIndex = useMemo(() => Fuse.parseIndex(searchIndexData), []);
const options = useMemo(
() => ({ keys: ["name", "tagline", "description", "link", "type"] }),
[],
);
const fuse = useMemo(
() => new Fuse(searchData, options, searchIndex),
[options, searchIndex],
);
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState([] as FuseResult<unknown>[]);
const handleSearch = (event: ChangeEvent<HTMLInputElement>) => {
const searchT = event.target.value;
setSearchTerm(searchT);
setResults(searchT ? fuse.search(searchT) : []);
};
return (
<div className="flex p-8 pb-20 gap-8 sm:p-20 font-[family-name:var(--font-geist-sans)] flex-col">
<p className="text-4xl">Search</p>
<input
type="text"
className="rounded-lg bg-white/15 h-10 text-white py-2 px-4 hover:border hover:border-white/25 active:border active:border-white/25 focus:border focus:border-white/25"
onChange={handleSearch}
/>
{!!results.length && (
<div className="w-full flex flex-col gap-3 items-center">
{results.map((res) => {
const hit = res["item"] as SearchItem;
return (
<Link
href={`${hit.type.toLowerCase()}/${hit.documentId}`}
key={`result-${hit?.documentId}`}
className="bg-white/10 flex p-3 rounded-lg items-center max-w-[600px] border border-white/10 hover:border-white/25 hover:bg-white/15 focus:border-white/25 focus:bg-white/15 active:border-white/25 active:bg-white/15 "
>
<div className="flex flex-col justify-start items-start">
<div className="bg-gray-200 text-black rounded-lg p-1 text-xs shrink font-semibold mb-2">
{hit.type}
</div>
<p className="font-bold text-lg">{hit.name}</p>
<p>
{hit.description.split(" ").slice(0, 15).join(" ") + "..."}
</p>
</div>
<div className="max-w-20 h-auto bg-white/15 rounded-lg p-3 ms-5">
<Image
src={hit.thumbnail || `/window.svg`}
height={120}
width={120}
alt={`${hit.name} search thumbnail`}
unoptimized={true}
/>
</div>
</Link>
);
})}
</div>
)}
{!!!results.length && searchTerm && (
<p className="w-full text-center font-bold">No results</p>
)}
{!!!searchTerm && (
<p className="w-full text-center font-bold">
Enter a term to see results
</p>
)}
</div>
);
}
export default Search;
On this page, the search data and serialized index are imported. The options
specify the keys to search(content type fields). Then, once the search index is deserialized, the index, the search data, and the options are passed to the FuseJs instance. When a user enters a search term, Fuse searches for a hit and returns all the items that match.
You can now demo the application by running:
turbo dev
Here's what the search page will look like:
Since this is a static site, add this setting to apps/dma-frontend/next.config.ts
:
const nextConfig: NextConfig = {
...
output: 'export'
};
Cloudflare Static Site Deployment: Updating Search Data After Deployment
To illustrate Cloudflare static site deployment and how to update the search data and index after this kind of static site is deployed, Cloudflare is used as an example hosting platform. Cloudflare Pages is a service that allows users to deploy static sites.
To deploy the Next.js site on Cloudflare, you'll first need to deploy your Strapi application elsewhere since all the content that the frontend depends on is hosted on it. Strapi provides a bunch of options for deployment. You can have a look at them on its documentation site. The recommended deployment option is Strapi Cloud, which allows you deploy Strapi to Production in just a few clicks.
Deploy Next.js Frontend to Cloudflare
To deploy the frontend, head over to the Cloudflare dashboard and under Workers & Pages > Overview, click the Create button, then under the Pages tab, you can choose to either deploy it by upload or using Git through Github or Gitlab. Set the value of the NEXT_PUBLIC_STRAPI_URL
env var to where your Strapi site is deployed, then apps/dma-frontend
as the root directory, then yarn generate-search-assets && yarn build
as the build command and out/
as the output directory.
Once it's deployed, head on over to the pages project and under its Settings > Build > Deploy Hooks, click the plus button. Name the hook and select a branch, then click Save. Copy the deploy hook url.
Create Strapi Webhook
To create a Strapi webhook, navigate to your Strapi dashboard under Settings > Global Settings > Webhooks, click Create new webhook. Add a name and paste the URL you copied earlier on the Cloudflare dashboard. Ensure that all the event checkboxes are ticked off. Then click Save. It should all look something like this:
So now, whenever content changes on Strapi, the whole Next.js site is built and the changes reflect on the search data and index.
GitHub Repo and Live Demo
You can find the entire code for this project on Github here. The live demo of this project can also be found here.
Conclusion
There are several ways you can search content on Strapi. These include through its REST and GraphQL APIs and third-party tools and services like Algolia, for example. However, due to factors like speed, performance, and cost it may not be the best option for static sites.
On the other hand, client-based search with Fuse.js Search Implementation, Strapi content management, Next.js and Cloudflare static site deployment remedies these issues on static sites. It's fast as no server requests are made, reliable as any chances of failure are near impossible after the site loads, inexpensive, and works overall if you'd like to take your site offline.
If you are building static sites with Strapi that have a moderate amount of data that doesn't change often, implementing client-based search with libraries like FuseJs would be a great option to consider. If you are interested in learning more about Strapi, check out its documentation.
Top comments (0)