Basic Serverless Typeahead Search with Cloudflare Workers

Vaibhav Sharma

·

March 12, 2021

Basic Serverless Typeahead Search with Cloudflare Workers

Typeahead search progressively searches and filters as the user types his/her query. It’s also called predictive search, incremental search or search-as-you-type and is an important feature of most search engines. In this tutorial, we’ll learn to make a very basic version of a Pokémon Typeahead Search using Cloudflare Workers and React. Cloudflare provides generous 100,000 requests per day in its free plan, making it perfect for an API like this.

Setup Cloudflare

We begin by signing up for a Cloudflare Workers account at workers.cloudflare.com.

cloudflare-workers-homepage

In the onboarding screen, select a unique subdomain for our workers.

setting-up-custom-subdomain

Installing Wrangler

To get the most out of Cloudflare Workers we need to install Wrangler CLI using yarn or npm.

yarn global add @cloudflare/wrangler

Next we login to our Cloudflare Account with Wrangler CLI

wrangler login

wrangler-login-terminal

This will open up a page on your browser where you can authorise the wrangler.

wrangler-login-browser

If it doesn’t work, you can manually log in using the config command and following the prompted instructions.

wrangler config

Setup A Worker Project

Wrangler CLI lets you set up a Cloudflare Worker project easily, as well as allows you to use Templates.

We’ll be using the TypeScript template for our project. To set it up just use the following command.

wrangler generate search-api https://github.com/cloudflare/worker-typescript-template

Next, navigate to the project and open it using your favourite IDE and we can start writing the serverless code.

wrangler-generate-template

But, before we begin to write our API, we need to add our accound_id in the project’s wrangler.toml file, as prompted while generating the project.

Writing the API

For our search to work there needs to be an index where our data is stored, here we’ll be using a JSON File of all the Pokémons and their ID, that I generated using PokeAPI.

[
  {
    "name": "bulbasaur",
    "id": 1
  },
  {
    "name": "ivysaur",
    "id": 2
  },
  {
    "name": "venusaur",
    "id": 3
  },
  {
    "name": "charmander",
    "id": 4
  },
  .
  .
  .
  {
    "name": "eternatus-eternamax",
    "id": 10217
  },
  {
    "name": "urshifu-single-strike-gmax",
    "id": 10218
  },
  {
    "name": "urshifu-rapid-strike-gmax",
    "id": 10219
  },
  {
    "name": "toxtricity-low-key-gmax",
    "id": 10220
  }
]

You can download and add it to the project’s src folder as pokedex.json.

But we can’t directly import a JSON module to our typescript file, for that we need to add the following to the project’s tsconfig.json file.

"resolveJsonModule": true,

Now let’s open our handler.ts file and start writing our API. We begin by importing our Pokémon Index file.

import pokedex from “./pokedex.json

Next, we get the pathname and query-string parameter from the Request object.

const {pathname, searchParams} = new URL(request.url)

Then we check if the pathname is correct, else we respond with a 404 error.

if (pathname ===/search”) {

} else return new Response("", {
   status: 404,
   statusText: "Path Not Found!"
   headers: {
       'Access-Control-Allow-Origin': '*',
       'Access-Control-Allow-Methods': 'GET',
   },
})

And if the path is correct, we extract the exact query params we need to process our search:

const query = searchParams.get(“q”)

Now, we write the filter for the Pokémons according to our query.

const d = pokedex.filter(
   (pokemon) =>
       pokemon.name.toString().toLowerCase().includes(query.toLowerCase()) ||
       pokemon.id.toString().toLowerCase().includes(query.toLowerCase()),
)

Finally, we return the stringified results in our Response object along with CORS headers.

return new Response(JSON.stringify(d), {
   headers: {
       'Access-Control-Allow-Origin': '*',
       'Access-Control-Allow-Methods': 'GET',
   },
})

Completed code should look like this:

import pokedex from './pokedex.json'


export async function handleRequest(request: Request): Promise<Response> {
 const { searchParams, pathname } = new URL(request.url)
 
 if (pathname === '/search') {
   const query = searchParams.get('q') || ''
   
   const d = pokedex.filter(
     (pokemon) =>
       pokemon.name.toString().toLowerCase().includes(query.toLowerCase()) ||
       pokemon.id.toString().toLowerCase().includes(query.toLowerCase()),
   )

   return new Response(JSON.stringify(d), {
     headers: {
       'Access-Control-Allow-Origin': '*',
       'Access-Control-Allow-Methods': 'GET',
     },
   })
 } else return new Response('', {
     status: 404,
     statusText: 'Path Not Found!',
     headers: {
       'Access-Control-Allow-Origin': '*',
       'Access-Control-Allow-Methods': 'GET',
     },
   })
}

Debugging and Publishing

Since this project is made with TypeScript we cannot directly run or publish it, for that we need a bundler like Webpack, which is preconfigured in the template we used.

To debug locally, we need to first run the dev script to compile the typescript.

yarn run dev

Then, we run the wrangler dev command.

wrangler dev

This should run the script on our localhost, where we can test it out by entering the following URL in our browser.

http://127.0.0.1:8787/search?q=pika

Which should return a list of all the entries for Pikachu in our index.

[
  {"name":"pikachu","id":25},
  {"name":"pikachu-rock-star","id":10080},
  {"name":"pikachu-belle","id":10081},
  {"name":"pikachu-pop-star","id":10082},
  {"name":"pikachu-phd","id":10083},
  {"name":"pikachu-libre","id":10084},
  {"name":"pikachu-cosplay","id":10085},
  {"name":"pikachu-original-cap","id":10094},
  {"name":"pikachu-hoenn-cap","id":10095},
  {"name":"pikachu-sinnoh-cap","id":10096},
  {"name":"pikachu-unova-cap","id":10097},
  {"name":"pikachu-kalos-cap","id":10098},
  {"name":"pikachu-alola-cap","id":10099},
  {"name":"pikachu-partner-cap","id":10148},
  {"name":"pikachu-gmax","id":10190}
]

Once satisfied, we can build and publish it to our account using Wrangler CLI.

To build in production mode, run the build script.

yarn run build

Then, run the publish command.

wrangler publish

Finally, the backend part of this project is complete and we can access the live API from the browser, it should be something like this.

https://search-api.<your-subdomain>.workers.dev/search?q=pika

Frontend

Although the API we made earlier is front-end agnostic, I’ll be bootstrapping the web app using Create React App.

yarn create react-app search-app --template typescript

We’ll begin by removing App.css, App.test.ts, index.css and logo.svg (don’t forget to remove the index.css import from index.tsx file).

To demonstrate the API I’ll be using Gestalt, a React Component Library by Pinterest and Axios to fetch the data from the API, let’s add it to our project.

yarn add axios @types/[email protected] [email protected]

Next, we add the Gestalt CSS file to our index.tsx

...
import "gestalt/dist/gestalt.css"
...

Then in the App.tsx file, we delete the pre-existing code, make it a fresh React Function Component and add the following code for our Search Field.

import React from 'react';
import {Box, Container, SearchField} from "gestalt";

const App: React.FC = () => {
   return (
       <>
           <Container>
               <Box flex="grow">
                   <SearchField
                       accessibilityLabel="Search"
                       placeholder="Search"
                       id="search"
                       onChange={({value}) => console.log(value)}
                   />
               </Box>
           </Container>
       </>
   );
};

export default App;

Next, we begin to build our search function by importing axios and useState, useCallback Hooks and adding the search function to the onChange attribute of Search Field.

import React, {useCallback, useState} from 'react';
import {Box, Container, SearchField} from "gestalt";
import axios from "axios";


const App: React.FC = () => {
   const [results, setResults] = useState([])

   const search = useCallback((query) => {
       axios
           .get(`${process.env.REACT_APP_SEARCH_API}/search?q=${query}`)
           .then(res => {
               const pokemons = res.data
               setResults(pokemons)
           })
           .catch(err => {
               console.error(err)

               setResults([])
           })
   }, [])

   return (
       <>
           <Container>
               <Box flex="grow">
                   <SearchField
                       accessibilityLabel="Search"
                       placeholder="Search"
                       id="search"
                       onChange={({value}) => search(value)}
                   />
               </Box>
           </Container>
       </>
   );
};

export default App;

Note that we are using an environment variable for our API, that’s just for ease but you can hard code the API if you like.

Finally, we write the code to display our results.

import React, {useCallback, useState} from 'react';
import {Avatar, Box, Container, SearchField, Text} from "gestalt";
import axios from "axios";


interface IPokemon {
   id: number,
   name: string
}

const App: React.FC = () => {
   const [results, setResults] = useState([])

   const search = useCallback((query) => {
       axios
           .get(`${process.env.REACT_APP_SEARCH_API}/search?q=${query}`)
           .then(res => {
               setResults(res.data)
           })
           .catch(err => {
               console.error(err)

               setResults([])
           })
   }, [])

   return (
       <>
           <Container>
               <Box flex="grow">
                   <SearchField
                       accessibilityLabel="Search"
                       placeholder="Search"
                       id="search"
                       onChange={({value}) => search(value)}
                   />
               </Box>

               {results.length
                   ? <Box paddingY={2}>
                       {results.map((pokemon: IPokemon) => {
                           const pokemonName = pokemon.name
                           const pokemonImage = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${pokemon.id}.png`

                           return <Box key={pokemon.id} borderStyle="sm" marginBottom={2} rounding="pill" padding={2}
                                       alignItems="center" display="flex">
                               <Box paddingX={2}>
                                   <Avatar
                                       name={pokemonName}
                                       src={pokemonImage}
                                       size="xs"
                                   />
                               </Box>
                         
                               <Box paddingX={2} flex="grow">
                                   <Text color="darkGray" weight="bold">
                                       {pokemonName.toUpperCase()}
                                   </Text>
                               </Box>
                           </Box>
                       })}
                   </Box>
                   : null}
           </Container>
       </>
   );
};

export default App;

The results should look something like this.

typeahead-search-final

We can deploy the react app with Cloudflare Pages or with Cloudflare Workers as well.

Copyright © 2018-2024 The Leaky Cauldron Blog. All Rights Reserved.