How to use structured content for page building
Learn how to create a page builder from structured content that can withstand the test of time and redesigns.
Go to How to use structured content for page buildingThis 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.
By following these steps, you can provide your users with fast, relevant search results while leveraging the benefits of Sanity's content platform.
Search
componentSearch
component to your pageIf you'd like to see a complete example - View Repo on Github
We will be using:
algoliasearch
@sanity/webhook
react-instantsearch
and react-instantsearch-nextjs
. 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
}
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"
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 }
);
}
}
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:
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._type == 'post'
{
"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.
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 }
);
}
}
After deployment you can view the Webhook attempts log to determine whether your webhook was successfully delivered.
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.
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.
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
.
Algolia provides documentation on indexing long documents
You will need to install react-instantsearch
and react-instantsearch-nextjs
packages.
npm install react-instantsearch react-instantsearch-nextjs
Algolia provides a detailed documentation on implementing Search in your React Application.
In the /app/components
directory add a new file Search.tsx
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 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
Algolia provides documentation on refining your search results, such as adding filters, synonyms, sorting strategies, search analytics, and more.
The example code in this guide can be found in https://github.com/sanity-io/sanity-next-algolia-example
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:
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.
Learn how to create a page builder from structured content that can withstand the test of time and redesigns.
Go to How to use structured content for page building