Last updated January 24, 2025

How to implement front-end search with Sanity

By Irina Blumenfeld

This guide provides a comprehensive walkthrough on integrating Sanity's structured content platform with Algolia's powerful search capabilities, covering webhooks, serverless functions, and an example front-end implementation using React and Next.js.

It will walk through how to set up indexing of your Sanity content in Algolia v5, including initial indexing of existing content and incremental updates as your content changes.

The webhook is set up in Sanity to trigger on create, update, and delete events for the specified content types. It sends the relevant data to the serverless function endpoint. The serverless function handles both the initial indexing of all existing content and the incremental updates triggered by the webhook.

By following these steps, you can provide your users with fast, relevant search results while leveraging the benefits of Sanity's content platform.

Steps to implement:

  1. Create schema in Sanity and create Next.js app
  2. Environment variables
  3. Deploy serverless function
  4. Setup webhook
  5. First time indexing
  6. Incremental indexing
  7. Indexing long records
  8. Create Search component
  9. Add Search component to your page

Protip

If you'd like to see a complete example - View Repo on Github

Create schema in Sanity and create Next.js app

We will be using:

Follow the instructions for Clean Next.js + Sanity app template to install and deploy your project.

The template includes a Next.js app with a Sanity Studio – an open-source React application that connects to your Sanity project’s hosted dataset.

Either start with a sample content included with the template, or create your own.

We will make it possible to search the post type documents using Algolia.
The post type has these fields:

{
  _id,
  title,
  slug,
  content,
  coverImage,
  date,
  _createdAt,
  _updatedAt
}

Environment variables

You must add NEXT_PUBLIC_ALGOLIA_APP_ID, ALGOLIA_API_KEY, and ALGOLIA_INDEX_NAME as environment variables in Vercel. You can find these in your Algolia account dashboard.

Algolia comes with a set of predefined API keys. Search API Key works on all your Algolia application indices and is safe to use in your production frontend code. Write API Key is used to create, update and DELETE your indices.

NEXT_PUBLIC_ALGOLIA_APP_ID="your-algolia-app-id"
ALGOLIA_API_KEY="your-algolia-api-key"
ALGOLIA_INDEX_NAME="name-of-your-index"

Deploy serverless function

The function will receive the webhook payload, extract the relevant data, and make the appropriate API calls to Algolia to update the search index.

We will be using Algolia Search API client for JavaScript which is part of the algoliasearch package.

We will also be using webhook-toolkit package to set up webhook request validation.

The function is calling Sanity client that is already defined in the Next.js template.

Install the following packages:

npm install @sanity/webhook algoliasearch

Create a new route in /app/api/algolia/route.ts

Inside your route in /app/api/algolia/route.ts add the contents of the serverless function that handles both initial indexing of all your content and incremental updates:

// app/api/algolia/route.ts

import { algoliasearch } from "algoliasearch";
import { client } from "@/sanity/lib/client";

const algoliaAppId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!;
const algoliaApiKey = process.env.ALGOLIA_API_KEY!;
const indexName = process.env.ALGOLIA_INDEX_NAME!;

const algoliaClient = algoliasearch(algoliaAppId, algoliaApiKey);

// Function to perform initial indexing
async function performInitialIndexing() {
  console.log("Starting initial indexing...");

  // Fetch all documents from Sanity
  const sanityData = await client.fetch(`*[_type == "post"]{
    _id,
    title,
    slug,
    "body": pt::text(content),
    _type,
    "coverImage": coverImage.asset->url,
    date,
    _createdAt,
    _updatedAt
  }`);

  const records = sanityData.map((doc: any) => ({
    objectID: doc._id,
    title: doc.title,
    slug: doc.slug.current,
     /**
     *  Truncating the body if it's too long. 
     *  Another approach: defining multiple records:
     *  https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/how-to/indexing-long-documents/
     */
    body: doc.body?.slice(0, 9500),
    coverImage: doc.coverImage,
    date: doc.date,
    _createdAt: doc._createdAt,
    _updatedAt: doc._updatedAt,
  }));

  // Save all records to Algolia
  await algoliaClient.saveObjects({
    indexName,
    objects: records,
  });

  console.log("Initial indexing completed.");
  return {
    message: "Successfully completed initial indexing!",
  };
}

export async function POST(request: Request) {
  try {
    const { searchParams } = new URL(request.url);
    const initialIndex = searchParams.get("initialIndex") === "true";

    // Perform initial indexing
    if (initialIndex) {
      const response = await performInitialIndexing();
      return Response.json(response);
    }

    // Incremental updates based on webhook payload
    let payload;
    try {
      payload = await request.json();
      console.log("Parsed Payload:", JSON.stringify(payload));
    } catch (jsonError) {
      console.warn("No JSON payload provided");
      return Response.json({ error: "No payload provided" }, { status: 400 });
    }

    const { _id, operation, value } = payload;

    if (!operation || !_id || !value) {
      return Response.json(
        { error: "Invalid payload, missing required fields" },
        { status: 400 }
      );
    }

    if (operation === "delete") {
      // Handle delete operation
      await algoliaClient.deleteObject({
        indexName,
        objectID: _id,
      });
      console.log(`Deleted object with ID: ${_id}`);
      return Response.json({
        message: `Successfully deleted object with ID: ${_id}`,
      });
    } else {
      // Add or update the document in Algolia
      await algoliaClient.saveObject({
        indexName,
        body: {
          ...value,
          objectID: _id,
        },
      });

      console.log(`Indexed/Updated object with ID: ${_id}`);
      return Response.json({
        message: `Successfully processed document with ID: ${_id}!`,
      });
    }
  } catch (error: any) {
    console.error("Error indexing objects:", error.message);
    return Response.json(
      { error: "Error indexing objects", details: error.message },
      { status: 500 }
    );
  }
}

Setup webhook

A Sanity webhook is used to notify Algolia whenever content is created, updated or deleted. This allows Algolia to keep its search index in sync with your Sanity content.

Here are the steps to set up the webhook in Sanity:

  1. Go to the Webhooks section under "API" in your Sanity project settings.
  2. Click "Create webhook".
  3. Give your webhook a name like "Algolia Indexing".
  4. For the URL, enter the URL of your Algolia indexing API endpoint (e.g. https://your-app.com/api/algolia). This is the URL of the serverless function you deployed in the previous step that will handle indexing your Sanity content in Algolia.
  5. Under "Trigger on", select "create", "update", and "delete" events.
  6. For the "Filter" field, enter a GROQ filter expression to index only the content types you want, e.g. _type == 'post'
  7. In the "Projection" field, a GROQ projection will determine what data will be sent to the serverless function for indexing.
{
  "transactionId": _rev,
  "projectId": sanity::projectId(),
  "dataset": sanity::dataset(),
  _id,
  // Returns a string value of "create", "update" or "delete" according to which operation was executed
  "operation": delta::operation(),
  // Define the payload
  "value": {
    "objectID": _id,
    "title": title,
    "slug": slug.current,
    // Portable text
    "body": pt::text(content),
    "_type": _type,
    "coverImage": coverImage.asset->url,
    "date": date,
    "_createdAt": _createdAt,
    "_updatedAt": _updatedAt
  }
}

8. Enter a secret - a string used by the function to block unwanted calls to the endpoint. Can be human readable or something harder to guess like a UUID. Take note of what you set here as it will be needed for the serverless function.

Webhook secret verifies events that Sanity sends to your serverless function

9. Click "Save" to save the webhook.

10. Add the secret to your .env file as: SANITY_WEBHOOK_SECRET

11. Add the env variable and webhook signature validation and update the serverless function:

// app/api/algolia/route.ts

import { algoliasearch } from "algoliasearch";
import { client } from "@/sanity/lib/client";
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
const algoliaAppId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!; const algoliaApiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY!; const indexName = process.env.ALGOLIA_INDEX_NAME!;
const webhookSecret = process.env.SANITY_WEBHOOK_SECRET!;
const algoliaClient = algoliasearch(algoliaAppId, algoliaApiKey); // Function to perform initial indexing async function performInitialIndexing() { console.log("Starting initial indexing..."); // Fetch all documents from Sanity const sanityData = await client.fetch(`*[_type == "post"]{ _id, title, slug, "body": pt::text(content), _type, "coverImage": coverImage.asset->url, date, _createdAt, _updatedAt }`); const records = sanityData.map((doc: any) => ({ objectID: doc._id, title: doc.title, slug: doc.slug.current, /** * Truncating the body if it's too long. * Another approach: defining multiple records: * https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/how-to/indexing-long-documents/ */ body: doc.body?.slice(0, 9500), coverImage: doc.coverImage, date: doc.date, _createdAt: doc._createdAt, _updatedAt: doc._updatedAt, })); // Save all records to Algolia await algoliaClient.saveObjects({ indexName, objects: records, }); console.log("Initial indexing completed."); return { message: "Successfully completed initial indexing!", }; } export async function POST(request: Request) { try { const { searchParams } = new URL(request.url); const initialIndex = searchParams.get("initialIndex") === "true"; // Perform initial indexing if (initialIndex) { const response = await performInitialIndexing(); return Response.json(response); }
// Validate webhook signature
const signature = request.headers.get(SIGNATURE_HEADER_NAME);
if (!signature) {
return Response.json(
{ success: false, message: "Missing signature header" },
{ status: 401 }
);
}
// Get request body for signature validation
const body = await request.text();
const isValid = await isValidSignature(body, signature, webhookSecret);
if (!isValid) {
return Response.json(
{ success: false, message: "Invalid signature" },
{ status: 401 }
);
}
// Incremental updates based on webhook payload let payload; try {
payload = JSON.parse(body);
console.log("Parsed Payload:", JSON.stringify(payload)); } catch (jsonError) { console.warn("No JSON payload provided"); return Response.json({ error: "No payload provided" }, { status: 400 }); } const { _id, operation, value } = payload; if (!operation || !_id || !value) { return Response.json( { error: "Invalid payload, missing required fields" }, { status: 400 } ); } if (operation === "delete") { // Handle delete operation await algoliaClient.deleteObject({ indexName, objectID: _id, }); console.log(`Deleted object with ID: ${_id}`); return Response.json({ message: `Successfully deleted object with ID: ${_id}`, }); } else { // Add or update the document in Algolia await algoliaClient.saveObject({ indexName, body: { ...value, objectID: _id, }, }); console.log(`Indexed/Updated object with ID: ${_id}`); return Response.json({ message: `Successfully processed document with ID: ${_id}!`, }); } } catch (error: any) { console.error("Error indexing objects:", error.message); return Response.json( { error: "Error indexing objects", details: error.message }, { status: 500 } ); } }

Protip

After deployment you can view the Webhook attempts log to determine whether your webhook was successfully delivered.

Indexing

First time indexing

If you’re indexing for the first time, you have to run the command below (after updating the localhost URL to the URL of your deployed serverless function) to start it and include query params initialIndex=true.

curl -X POST "http://localhost:3000/api/algolia?initialIndex=true"

After you run the above command, it will create a new named index in Algolia and it will use Algolia’s v5 function saveObjects to add existing documents to the index.

Incremental indexing

For incremental indexing, the Webhook provides operation parameter, and it will either be create, update or delete. The code in serverless function uses Algolia v5 (deleteObject or saveObject) accordingly to update it’s index incrementally.

With this setup, your Sanity content will be automatically indexed in Algolia whenever it is created, updated, or deleted.

Screenshots of the Sanity Studio with matching indexed data in Algolia

Indexing long records

Your Algolia plan has limits on the number of records and the size of records you can import. If you exceed these limits, you might get an error: Algolia error: Record too big.

To work around this Algolia suggests to break the page into sections or even paragraphs, and store each as a separate record. When you split a page, the same content might appear in multiple records. To avoid duplicates, you can turn on distinct and set attributeForDistinct.

Protip

Algolia provides documentation on indexing long documents

Create Search Component

You will need to install react-instantsearch and react-instantsearch-nextjs packages.

npm install react-instantsearch react-instantsearch-nextjs

Protip

Algolia provides a detailed documentation on implementing Search in your React Application.

In the /app/components directory add a new file Search.tsx

Gotcha

Make sure to use the Search API Key provided by Algolia - a public API key which can be safely used in your frontend.

// app/components/Search.tsx

'use client';
import { liteClient as algoliasearch } from 'algoliasearch/lite';
import { SearchBox, Hits } from 'react-instantsearch';
import { InstantSearchNext } from 'react-instantsearch-nextjs';
import { useState } from 'react';
import Link from 'next/link';

const algoliaAppId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!;
const algoliaApiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY!;
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME!;

const searchClient = algoliasearch(algoliaAppId, algoliaApiKey);

export function Search() {

    const [results, setResults] = useState('');

    return (
        <InstantSearchNext
            indexName={indexName}
            searchClient={searchClient}
            initialUiState={{
                'my-index': { query: '' },
            }}
            onStateChange={({ uiState }) => {
                setResults(uiState['my-index']?.query || '');
            }}
            future={{ preserveSharedStateOnUnmount: true }}
            routing={{
                router: {
                    cleanUrlOnDispose: false,
                    windowTitle(routeState) {
                        const indexState = routeState.indexName || {};
                        return indexState.query
                            ? `MyWebsite - Results for: ${indexState.query}`
                            : 'MyWebsite - Results page';
                    },
                }
            }}
        >
            {/* SearchBox for input */}
            <SearchBox
                placeholder="Search for items..."
                classNames={{
                    input: 'border p-2 rounded border-gray-300 m-5 w-1/2',
                    submit: 'hidden',
                    reset: 'hidden',
                }}
            />

            {/* Hits component to display results */}
            {results && (
                <div className="text-left">
                    <h2 className="text-2xl font-semibold">Results for: {results}</h2>

                    <Hits
                        hitComponent={({ hit }) => (
                            <div className="p-2 border-b">
                                <Link href={`/posts/${hit.slug}`} passHref className='text-red-500 hover:text-red-600 hover:underline'>
                                    {hit.title}
                                </Link>
                                <p>{hit.description}</p>
                            </div>
                        )}
                    />
                </div>
            )}
        </InstantSearchNext>
    );
}

Add Search Component to the Page

Add your Search to the front-end component in your site.

In our example we'll add it to the main page in /app/page.tsx

// /app/page.tsx

... rest of the imports
import { Search } from "@/app/components/Search";
export const dynamic = 'force-dynamic';

... page content

<Search />

... page content

If you are indexing for the first time, follow instructions for the Initial Indexing.

Run your project and test to make sure you're getting search results: npm run dev

Our search results showing on the page

Protip

Algolia provides documentation on refining your search results, such as adding filters, synonyms, sorting strategies, search analytics, and more.

Front-end Implementation Demo

The example code in this guide can be found in https://github.com/sanity-io/sanity-next-algolia-example

Conclusion

By integrating Sanity and Algolia, you can provide powerful search capabilities for your content. This guide walked through the steps to set up indexing of your Sanity content in Algolia, including:

  • Deploying a serverless function to handle initial indexing of all existing content
  • Incremental updates triggered by a Sanity webhook
  • Setting up Algolia search in your front-end application and using the Algolia JavaScript API client to send search queries and display the results.

Sanity – build remarkable experiences at scale

Sanity Composable Content Cloud is the headless CMS that gives you (and your team) a content backend to drive websites and applications with modern tooling. It offers a real-time editing environment for content creators that’s easy to configure but designed to be customized with JavaScript and React when needed. With the hosted document store, you query content freely and easily integrate with any framework or data source to distribute and enrich content.

Sanity scales from weekend projects to enterprise needs and is used by companies like Puma, AT&T, Burger King, Tata, and Figma.

Other guides by author