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.
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.
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.
To build this project, you will need:
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:
1{
2 "$schema": "https://turborepo.org/schema.json",
3 "tasks": {
4 "develop": {
5 "cache": false
6 },
7 "dev": {
8 "cache": false,
9 "dependsOn": ["develop"]
10 },
11 "build": {
12 "dependsOn": ["^build"],
13 "outputs": [".next/**", "!.next/cache/**", "dist/**"]
14 },
15 "generate-search-assets": {
16 "cache": false
17 }
18 }
19}
The turbo configuration contains four tasks:
develop
which runs Strapi's strapi develop
dev
which runs next dev
build
which runs strapi build
and next 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:
1{
2 "name": "search",
3 "packageManager": "yarn@4.2.2",
4 "private": true,
5 "workspaces": ["apps/*"],
6 "scripts": {
7 "dev": "turbo run dev --parallel",
8 "generate-search-assets": "turbo run generate-search-assets"
9 }
10}
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.
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.
To illustrate how this type of search can work across multiple content types and fields, the app will have three Strapi content types:
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).
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.
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.
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.
find
and findOne
for Categoryfind
and findOne
for Servicefind
and findOne
for ShowcaseThe 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.
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
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 |
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 |
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 |
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 indexapps/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 placedThe 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:
1const qs = require("qs");
2const Fuse = require("fuse.js");
3const fs = require("fs");
4const path = require("path");
5const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
6
7/*
8 * Downloads images from Strapi to the apps/dma-frontend/public folder
9 * as the site will be static and for the purposes of
10 * this tutorial, Strapi won't be deployed.
11 *
12 */
13async function saveImages(formats) {
14 const saveImage = async (imageUrl) => {
15 const pathFolders = imageUrl.split("/");
16 const imagePath = path.join(
17 path.resolve(__dirname, "../public"),
18 ...pathFolders,
19 );
20
21 try {
22 const response = await fetch(`${strapiUrl}${imageUrl}`);
23
24 if (!response.ok) {
25 throw new Error(`Failed to fetch image: ${response.statusText}`);
26 }
27
28 const arrayBuffer = await response.arrayBuffer();
29 const buffer = Buffer.from(arrayBuffer);
30
31 fs.writeFileSync(imagePath, buffer);
32 console.log(`Image successfully saved to ${imagePath}`);
33 } catch (error) {
34 console.error(`Error downloading the image: ${error.message}`);
35 }
36 };
37
38 for (let size of ["thumbnail", "small", "medium", "large"]) {
39 saveImage(formats[size].url);
40 }
41}
42
43/*
44 * Fetches data from Strapi, formats it using Fuse.js,
45 * and creates search list and a search index. Saves
46 * both to files within apps/dma-frontend/app/lib/data.
47 */
48async function generateIndex() {
49 // Strapi query to populate services, showcases, and
50 // their images within the category data
51 const query = qs.stringify(
52 {
53 populate: {
54 services: {
55 populate: {
56 cover_image: true,
57 showcases: {
58 populate: {
59 cover_image: true,
60 },
61 },
62 },
63 },
64 },
65 },
66 {
67 encodeValuesOnly: true,
68 },
69 );
70 const resp = await fetch(`${strapiUrl}/api/categories?${query}`, {
71 method: "GET",
72 });
73
74 if (!resp.ok) {
75 const err = await resp.text();
76
77 try {
78 const errResp = JSON.parse(err);
79 console.log(errResp);
80 } catch (err) {
81 console.log(`There was a problem fetching data from Strapi: ${err}`);
82 }
83 } else {
84 let indexData = [];
85 let respData = [];
86 const body = await resp.json();
87
88 if (body?.error) {
89 console.log(
90 `There was a problem fetching data from Strapi: ${body.error}`,
91 );
92 return;
93 } else {
94 respData = body?.data || body;
95 }
96
97 // The search index data is created here
98 respData.forEach((cat) => {
99 if (cat["services"]) {
100 cat["services"].forEach((service) => {
101 if (service["showcases"]) {
102 service["showcases"].forEach((showcase) => {
103 saveImages(showcase.cover_image.formats);
104 showcase["type"] = "Showcases";
105 showcase["thumbnail"] =
106 showcase.cover_image.formats.thumbnail.url;
107
108 for (let key of [
109 "id",
110 "cover_image",
111 "createdAt",
112 "updatedAt",
113 "publishedAt",
114 ]) {
115 delete showcase[key];
116 }
117
118 indexData.push(showcase);
119 });
120 }
121
122 saveImages(service.cover_image.formats);
123
124 service["thumbnail"] = service.cover_image.formats.thumbnail.url;
125 service["type"] = "Services";
126
127 for (let key of [
128 "showcases",
129 "cover_image",
130 "id",
131 "createdAt",
132 "updatedAt",
133 "publishedAt",
134 ]) {
135 delete service[key];
136 }
137
138 indexData.push(service);
139 });
140 }
141
142 for (let key of [
143 "services",
144 "id",
145 "createdAt",
146 "updatedAt",
147 "publishedAt",
148 ]) {
149 delete cat[key];
150 }
151
152 cat["type"] = "Categories";
153
154 indexData.push(cat);
155 });
156
157 // The search index is pre-generated here
158 const fuseIndex = Fuse.createIndex(
159 ["name", "description", "link", "type"],
160 indexData,
161 );
162
163 // The search list and search index are written
164 // to apps/dma-frontend/app/lib/data here
165 const writeToFile = (fileName, fileData) => {
166 const fpath = path.join(
167 path.resolve(__dirname, "../app/lib/data"),
168 `${fileName}.json`,
169 );
170
171 fs.writeFile(fpath, JSON.stringify(fileData), (err) => {
172 if (err) {
173 console.error(err);
174 } else {
175 console.log(`Search data file successfully written to ${fpath}`);
176 }
177 });
178 };
179
180 writeToFile("search_data", indexData);
181 writeToFile("search_index", fuseIndex.toJSON());
182 }
183}
184
185generateIndex();
The code above does two things.
apps/dma-frontend/app/lib/data
folder.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:
1 "scripts": {
2 ...
3 "generate-search-assets": "node strapi/gen-search-assets.js"
4 }
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:
1"use client";
2
3import Fuse, { FuseResult } from "fuse.js";
4import searchData from "@/app/lib/data/search_data.json";
5import searchIndexData from "@/app/lib/data/search_index.json";
6import { ChangeEvent, useMemo, useState } from "react";
7import { SearchItem } from "@/app/lib/definitions/search";
8import Image from "next/image";
9import Link from "next/link";
10
11function Search() {
12 const searchIndex = useMemo(() => Fuse.parseIndex(searchIndexData), []);
13 const options = useMemo(
14 () => ({ keys: ["name", "tagline", "description", "link", "type"] }),
15 [],
16 );
17
18 const fuse = useMemo(
19 () => new Fuse(searchData, options, searchIndex),
20 [options, searchIndex],
21 );
22
23 const [searchTerm, setSearchTerm] = useState("");
24 const [results, setResults] = useState([] as FuseResult<unknown>[]);
25
26 const handleSearch = (event: ChangeEvent<HTMLInputElement>) => {
27 const searchT = event.target.value;
28 setSearchTerm(searchT);
29 setResults(searchT ? fuse.search(searchT) : []);
30 };
31
32 return (
33 <div className="flex p-8 pb-20 gap-8 sm:p-20 font-[family-name:var(--font-geist-sans)] flex-col">
34 <p className="text-4xl">Search</p>
35 <input
36 type="text"
37 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"
38 onChange={handleSearch}
39 />
40 {!!results.length && (
41 <div className="w-full flex flex-col gap-3 items-center">
42 {results.map((res) => {
43 const hit = res["item"] as SearchItem;
44
45 return (
46 <Link
47 href={`${hit.type.toLowerCase()}/${hit.documentId}`}
48 key={`result-${hit?.documentId}`}
49 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 "
50 >
51 <div className="flex flex-col justify-start items-start">
52 <div className="bg-gray-200 text-black rounded-lg p-1 text-xs shrink font-semibold mb-2">
53 {hit.type}
54 </div>
55 <p className="font-bold text-lg">{hit.name}</p>
56 <p>
57 {hit.description.split(" ").slice(0, 15).join(" ") + "..."}
58 </p>
59 </div>
60 <div className="max-w-20 h-auto bg-white/15 rounded-lg p-3 ms-5">
61 <Image
62 src={hit.thumbnail || `/window.svg`}
63 height={120}
64 width={120}
65 alt={`${hit.name} search thumbnail`}
66 unoptimized={true}
67 />
68 </div>
69 </Link>
70 );
71 })}
72 </div>
73 )}
74 {!!!results.length && searchTerm && (
75 <p className="w-full text-center font-bold">No results</p>
76 )}
77 {!!!searchTerm && (
78 <p className="w-full text-center font-bold">
79 Enter a term to see results
80 </p>
81 )}
82 </div>
83 );
84}
85
86export 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
:
1const nextConfig: NextConfig = {
2 ...
3 output: 'export'
4};
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.
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.
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.
You can find the entire code for this project on Github here. The live demo of this project can also be found here.
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.
I am a developer and writer passionate about performance optimization, user-centric designs, and scalability.