DEV Community

Cover image for Client-Side Search for Static Sites with Strapi, Next.js, Fusejs & Cloudflare
Strapi for Strapi

Posted on • Originally published at strapi.io

Client-Side Search for Static Sites with Strapi, Next.js, Fusejs & Cloudflare

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.

Demo

Prerequisites

To build this project, you will need:

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
Enter fullscreen mode Exit fullscreen mode

Both the frontend and the backend are contained within the apps folder.

Next, initialize a workspace using yarn:

yarn init -w
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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).

001.png

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).

002.png

This is its category relation.

003.png

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.

004.png

This is its service relation.

005.png

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

007.png

Allow Endpoint for find and findOne for Service

008.png

Allow Endpoint for find and findOne for Showcase

009.png

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 ..
Enter fullscreen mode Exit fullscreen mode

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:

010.png

The Service Category Page

011.png

The Service Page

012.png

This Showcase Page

013.png

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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"
 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add the search page:

touch apps/dma-frontend/app/search/page.tsx
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
};
Enter fullscreen mode Exit fullscreen mode

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:

016.png

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)