A powerful, feature-rich plugin to sync your Payload CMS collections with Algolia to enable fast and extensive search capabilities.
- Overview
- Installation
- Quick Start
- Configuration
- API Reference
- Advanced Features
- Examples
- Troubleshooting
- License
The Payload Algolia Search Plugin bridges your Payload CMS with Algolia's powerful search infrastructure, providing:
- 🔄 Automatic Syncing: Real-time synchronization of your Payload collections with Algolia
- ⚡ Fast Search: Lightning-fast search capabilities powered by Algolia
- 🎯 Flexible Configuration: Granular control over which collections and fields to index
- đź”’ Secure: Built-in access control and secure API endpoints
- 🛠️ Developer-Friendly: Comprehensive customization options and hooks
- Automatic Syncing: Documents are automatically synced when created, updated, or deleted
- Collection-Specific Configuration: Choose exactly which collections and fields to index
- Admin UI Integration: Built-in re-index button in the Payload admin panel
- RESTful Endpoints: Dedicated endpoints for search and re-indexing operations
- Result Enrichment: Option to fetch fresh, access-controlled data from Payload
- Custom Transformers: Transform complex field types for optimal search indexing
- Access Control: Fine-grained permissions for re-indexing operations
- Auto-Configuration: Automatic Algolia index setup on server start
Install the plugin using your preferred package manager:
# pnpm (recommended)
pnpm add @veiag/payload-algolia-search
# npm
npm install @veiag/payload-algolia-search
# yarn
yarn add @veiag/payload-algolia-searchAdd the plugin to your payload.config.ts:
import { buildConfig } from 'payload/config'
import { algoliaSearchPlugin } from '@veiag/payload-algolia-search'
export default buildConfig({
// ... your existing config
plugins: [
algoliaSearchPlugin({
credentials: {
appId: process.env.ALGOLIA_APP_ID!,
apiKey: process.env.ALGOLIA_API_KEY!, // Admin API Key
indexName: process.env.ALGOLIA_INDEX_NAME!,
},
collections: [
{
slug: 'posts',
indexFields: ['title', 'content', 'tags'],
},
],
}),
],
})Create a .env file with your Algolia credentials:
ALGOLIA_APP_ID=your_app_id
ALGOLIA_API_KEY=your_admin_api_key
ALGOLIA_INDEX_NAME=your_index_nameThe plugin will automatically:
- Configure your Algolia index (if it exists)
- Set up search and re-index endpoints
To index existing documents, use re-index endpoints (or button in admin UI)
The plugin accepts a configuration object with the following options:
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
credentials |
PluginAlgoliaCredentials |
âś… | - | Algolia connection details |
collections |
CollectionAlgoliaConfig[] |
âś… | - | Collections to sync with Algolia |
searchEndpoint |
string | false |
❌ | '/search' |
Search endpoint path (set to false to disable) |
overrideAccess |
boolean |
❌ | false |
If true, the plugin will override access control when enriching search results |
reindexEndpoint |
string | false |
❌ | '/reindex' |
Re-index endpoint path (set to false to disable) |
configureIndexOnInit |
boolean |
❌ | true |
Auto-configure Algolia index on startup |
hideReindexButton |
boolean |
❌ | false |
Hide re-index button in admin UI |
reindexAccess |
function |
❌ | ( req ) => !!req.user |
Access control for re-index operations |
fieldTransformers |
Record<string, FieldTransformer> |
❌ | - | Custom field transformation functions |
disabled |
boolean |
❌ | false |
Disable the plugin entirely |
Each collection in the collections array supports:
interface CollectionAlgoliaConfig {
slug: string; // Collection slug
indexFields: string[]; // Fields to index in Algolia
}| Variable | Description | Required |
|---|---|---|
ALGOLIA_APP_ID |
Your Algolia Application ID | âś… |
ALGOLIA_API_KEY |
Your Algolia Admin/Write API Key | âś… |
ALGOLIA_INDEX_NAME |
Target Algolia index name | âś… |
⚠️ Security Note: TheALGOLIA_API_KEYshould be your Admin/Write API Key and must be kept secret. Never expose it in client-side code.
Perform search queries against your Algolia index.
Endpoint: GET /search (or your configured searchEndpoint)
| Parameter | Type | Description |
|---|---|---|
query |
string |
Search term |
enrichResults |
boolean |
Fetch fresh documents from Payload |
select |
object |
Field selection for enriched results |
depth |
object |
Depth options for enriched results |
hitsPerPage |
number |
Number of results per page |
filters |
string |
Algolia filters |
| Any other Algolia search parameter | varies | Passed directly to Algolia |
// Simple search
const response = await fetch('/search?query=javascript&hitsPerPage=10');
const results = await response.json();Manually trigger a full re-index of a collection.
Endpoint: POST /reindex/:collectionSlug
// Re-index the 'posts' collection
const response = await fetch('/reindex/posts', { method: 'POST' });
const result = await response.json();By default, search results come directly from Algolia for maximum speed. However, you can enable result enrichment to get fresh, access-controlled data from your Payload database.
- Data Freshness: Guaranteed up-to-date information from your database
- Security: Respects Payload's access control rules
- Metadata Preservation: Keeps Algolia's search metadata (highlights, snippets)
Add enrichResults=true to your search query:
const response = await fetch('/search?query=javascript&enrichResults=true');
const { hits, enrichedHits, ...algoliaMetadata } = await response.json();
// hits: Original Algolia results with search metadata
// enrichedHits: Fresh documents from Payload (keyed by ID){
"hits": [
{
"objectID": "60c7c5d5f1d2a5001f6b0e3d",
"title": "JavaScript Basics",
"_highlightResult": { "title": { "value": "<em>JavaScript</em> Basics" } }
}
],
"enrichedHits": {
"60c7c5d5f1d2a5001f6b0e3d": {
"id": "60c7c5d5f1d2a5001f6b0e3d",
"title": "JavaScript Basics",
"content": "Full article content...",
"author": { "name": "John Doe" },
"updatedAt": "2024-01-15T10:30:00Z"
}
},
"nbHits": 1,
"page": 0
}Control which fields are returned in enriched results to optimize response size and performance.
import qs from 'qs-esm';
// Include only specific fields
const selectFields = {
posts: { title: true, slug: true },
authors: { name: true, email: true }
};
const params = {
query: 'javascript',
enrichResults: true,
select: selectFields
};
const url = `/search?${qs.stringify(params)}`;Inclusion (recommended):
{
posts: { title: true, content: true },
authors: { name: true }
}Exclusion:
{
posts: { internalNotes: false, draft: false }
}Control the depth of relationship population in enriched results to optimize performance and control data fetching.
- Performance Optimization: Prevent over-fetching of deeply nested relationships
- Bandwidth Control: Reduce response payload size
- Granular Control: Different depth levels per collection type
Add depth parameters to your search query to specify how deeply relationships should be populated for each collection:
import qs from 'qs-esm';
// Set different depths for different collections
const depthConfig = {
posts: 3, // Populate posts to depth 3
authors: 1, // Populate authors to depth 1
categories: 2 // Populate categories to depth 2
};
const params = {
query: 'javascript',
enrichResults: true,
depth: depthConfig
};
const url = `/search?${qs.stringify(params)}`;
// URL EXAMPLE
/search?query=javascript&enrichResults=true&depth[posts]=3&depth[authors]=1&depth[categories]=2You can combine depth control with field selection for maximum optimization:
const params = {
query: 'javascript',
enrichResults: true,
select: {
posts: { title: true, content: true, author: true },
authors: { name: true, bio: true }
},
depth: {
posts: 2, // Populate author relationship
authors: 0 // Don't populate further relationships in authors
}
};Default Behavior
- If no depth is specified for a collection, it defaults to depth 1
- Invalid depth values are ignored and fall back to the default
- Depth must be a non-negative integer (0, 1, 2, 3, etc.)
Transform complex field types into searchable formats before indexing in Algolia.
- Group Fields: Flatten nested data structures
- Custom Fields: Handle proprietary field types
- Complex Data: Convert objects/arrays to searchable strings
type FieldTransformer = (
value: unknown,
fieldConfig: Field,
collectionSlug: string
) => string | number | boolean | string[] | null;// Collection with group field
const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{
name: 'authorDetails',
type: 'group',
fields: [
{ name: 'name', type: 'text' },
{ name: 'title', type: 'text' },
{ name: 'bio', type: 'textarea' },
],
},
],
};
// Plugin configuration
algoliaSearchPlugin({
// ... other config
collections: [
{
slug: 'posts',
indexFields: ['title', 'authorDetails'], // Include the group field
},
],
fieldTransformers: {
group: (value, fieldConfig, collectionSlug) => {
if (fieldConfig.name === 'authorDetails' && value) {
const { name, title, bio } = value as any;
return [name, title, bio].filter(Boolean).join(' ');
}
return null; // Don't index other group fields
},
},
});The plugin includes default transformers for:
text: Just return the value as-is, or in case of an array, join the elements with a commarichText: Converts rich text to plain textrelationship: Extracts related document titles or names (returnsvalue.title,value.name,value.slug, orString(value.id))upload: Indexes file names and metadata (usesvalue?.filename,value?.alt,value?.title, ornull)select: Handles select field valuesarray: Joins array elements into a comma-separated string; if elements are objects, their values are concatenated into a single string before joining.
Control who can trigger re-indexing operations.
By default, any authenticated user can trigger re-indexing:
const defaultAccess = ( req: PayloadRequest ) => !!req.user;Restrict access to specific user roles:
algoliaSearchPlugin({
// ... other config
reindexAccess: ( req ) => {
return req.user?.role === 'admin' || req.user?.role === 'editor';
},
});Hide the re-index button while keeping the endpoint active:
algoliaSearchPlugin({
// ... other config
hideReindexButton: true,
});import { algoliaSearchPlugin } from '@veiag/payload-algolia-search';
export default buildConfig({
collections: [Posts, Authors, Categories],
plugins: [
algoliaSearchPlugin({
credentials: {
appId: process.env.ALGOLIA_APP_ID!,
apiKey: process.env.ALGOLIA_API_KEY!,
indexName: process.env.ALGOLIA_INDEX_NAME!,
},
collections: [
{
slug: 'posts',
indexFields: ['title', 'excerpt', 'content', 'tags'],
},
{
slug: 'authors',
indexFields: ['name', 'bio'],
},
],
}),
],
});algoliaSearchPlugin({
credentials: {
appId: process.env.ALGOLIA_APP_ID!,
apiKey: process.env.ALGOLIA_API_KEY!,
indexName: process.env.ALGOLIA_INDEX_NAME!,
},
collections: [
{
slug: 'products',
indexFields: ['title', 'description', 'category', 'brand', 'sku','specifications'],
},
],
fieldTransformers: {
group: (value, fieldConfig) => {
if (fieldConfig.name === 'specifications' && value) {
return Object.entries(value)
.map(([key, val]) => `${key}: ${val}`)
.join(' ');
}
return null;
},
},
});// React search component example with depth control
const SearchResults = ({ query }) => {
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const searchProducts = async () => {
if (!query) return;
setLoading(true);
try {
const params = {
query,
enrichResults: true,
hitsPerPage: 20,
depth: {
products: 2, // Include category and brand relationships
categories: 1 // Don't over-fetch category relationships
},
select: {
products: { title: true, description: true, price: true, category: true, brand: true },
categories: { name: true, slug: true }
}
};
const response = await fetch(`/search?${qs.stringify(params)}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
};
searchProducts();
}, [query]);
if (loading) return <div>Searching...</div>;
if (!results) return null;
return (
<div>
<p>{results.nbHits} results found</p>
{results.hits.map((hit) => {
const enrichedData = results.enrichedHits[hit.objectID];
return (
<div key={hit.objectID}>
<h3 dangerouslySetInnerHTML={{
__html: hit._highlightResult.title.value
}} />
{enrichedData && (
<>
<p>{enrichedData.description}</p>
{enrichedData.category && (
<span>Category: {enrichedData.category.name}</span>
)}
</>
)}
</div>
);
})}
</div>
);
};Symptoms: Documents aren't appearing in Algolia after creation/updates.
Solutions:
- Verify your Algolia credentials are correct
- Check that
indexFieldsincludes existing fields - Ensure the API key has write permissions
- Check server logs for error messages
Symptoms: Search requests fail with 404 errors.
Solutions:
- Verify
searchEndpointis not set tofalse - Check your server is running and the plugin is loaded
- Ensure the endpoint path doesn't conflict with existing routes
Symptoms: No re-index button in the admin panel.
Solutions:
- Check that
hideReindexButtonis not set totrue - Ensure the collection is configured in the plugin
Symptoms: enrichedHits is empty even with enrichResults=true.
Solutions:
- Verify documents exist in your Payload database
- Check access control permissions for the requesting user
- Ensure document IDs in Algolia match Payload document IDs
Current Limitation: This plugin does not currently support Payload's localization features. Localized fields will not be indexed correctly.
Workarounds:
- Single Locale: Configure your collections to use only one locale for now
- Manual Field Mapping: Create separate non-localized fields specifically for search indexing
For collections with many documents:
- Use field selection to limit response size
- Implement pagination with
hitsPerPage - Consider indexing only essential fields initially
- Use enrichment sparingly for better performance
- Cache search results on the frontend when appropriate
- Consider using Algolia's faceting for filters instead of enrichment
We welcome contributions!