Help us improve
Share bugs, ideas, or general feedback.
From algolia
The Contentful ↔ Algolia integration we ship by default on Composable DXP engagements — choosing between the Marketplace app, the Ingestion API source connector, and a custom Vercel-Function indexer; mapping Topics & Assemblies to Algolia records; per-locale fan-out; preview vs. delivery indices; webhook signature verification; on-publish revalidation; backfills; the moments where the Marketplace app is enough and the moments where it's not. Use this skill any time a Composable DXP engagement needs Contentful as the source of truth for an Algolia index — first integration, new content type, locale rollout, or migration from a hand-rolled indexer to a managed connector (or vice versa).
npx claudepluginhub bpainter/composable-dxp-claude-marketplace --plugin algoliaHow this skill is triggered — by the user, by Claude, or both
Slash command
/algolia:algolia-contentful-integrationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill puts you in the role of an engineer who has shipped this integration enough times to know the forks. Default posture: **start with the Contentful Marketplace Algolia app for simple cases; graduate to a custom Vercel-Function indexer for anything that needs to denormalize Topics & Assemblies, fan out locales non-trivially, or compute fields from outside Contentful.**
Guides technical evaluation of code review feedback: read fully, restate for understanding, verify against codebase, respond with reasoning or pushback before implementing.
Share bugs, ideas, or general feedback.
This skill puts you in the role of an engineer who has shipped this integration enough times to know the forks. Default posture: start with the Contentful Marketplace Algolia app for simple cases; graduate to a custom Vercel-Function indexer for anything that needs to denormalize Topics & Assemblies, fan out locales non-trivially, or compute fields from outside Contentful.
Pair with contentful-content-model (the source structure), contentful-webhooks (the trigger surface), contentful-graphql (the query layer for non-trivial mappings), algolia-index-design (the destination structure), algolia-indexing-pipeline (the operational scaffolding), and algolia-api-keys-security (the keys the integration uses).
articles, Glossary Terms → glossary_terms. Assemblies (Pages, PageSections) usually don't get indexed directly — they're navigation, not search results.objectID = ${sys.id}-${locale}.*_preview index in a non-prod app.Does the mapping fit "1 entry → 1 record per locale, with simple field mapping"?
├── Yes
│ ├── Locales: ≤ 5 with consistent field-level localization → Marketplace app
│ ├── Locales: many or with fallback chains → Custom indexer
│ └── Computed fields needed (popularity, denormalized author) → Custom indexer
└── No (denormalizes references, flattens assemblies, multi-source records, etc.)
└── Custom indexer
The Marketplace app:
Limits:
sys.id and a few top-level fields, but not deep traversal).name and role flattened in, not just the author_id).body field).The custom indexer:
app/api/algolia/sync/route.ts).partialUpdateObjects or saveObjects.Contentful (source of truth)
│
│ Webhook on Entry Published / Unpublished / Deleted
▼
Vercel Function (POST /api/algolia/sync)
│
├─ Verify webhook signature (HMAC)
├─ Fetch full entry via GraphQL (linked refs resolved)
├─ Map to Algolia record(s) — one per locale
└─ saveObjects / deleteObjects on the right index
│
▼
Algolia index (e.g., articles)
[Scheduled cron: nightly reindex from Contentful for drift repair]
In Contentful → Settings → Webhooks:
https://{site}/api/algolia/syncEntry.publish, Entry.unpublish, Entry.deleteEntry.archive, Entry.unarchiveX-Contentful-Webhook-Source: cms (custom header to disambiguate from other webhooks).CONTENTFUL_WEBHOOK_SECRET (used for HMAC verification).// lib/algolia/mappers/article.ts
import type { Entry } from 'contentful';
export function mapArticleToAlgolia(entry: Entry, locale: string) {
const fields = entry.fields as ArticleFields;
const author = fields.author?.[locale]?.fields ?? {};
return {
objectID: `${entry.sys.id}-${locale}`,
type: 'article',
title: fields.title?.[locale] ?? '',
summary: fields.summary?.[locale] ?? '',
body_plaintext: stripHtml(fields.body?.[locale] ?? ''),
topics: fields.topics?.[locale] ?? [],
audience: fields.audience?.[locale] ?? [],
author_id: entry.fields.author?.[locale]?.sys?.id ?? null,
author_name: author.name ?? null,
publishedAt_unix: fields.publishedAt?.[locale]
? Math.floor(new Date(fields.publishedAt[locale]).getTime() / 1000)
: null,
locale,
slug: fields.slug?.[locale] ?? '',
url: `/articles/${fields.slug?.[locale]}`,
image: fields.heroImage?.[locale]?.fields?.file?.url ?? null,
popularity: 0, // backfilled by analytics job
};
}
function stripHtml(input: string) {
return input.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
For Rich Text fields, use @contentful/rich-text-html-renderer to render to HTML, then strip. Or use @contentful/rich-text-plain-text-renderer directly.
// app/api/algolia/sync/route.ts
import { NextResponse } from 'next/server';
import crypto from 'node:crypto';
import { algoliasearch } from 'algoliasearch';
import { getEntryWithRefs } from '@/lib/contentful/server';
import { mapArticleToAlgolia } from '@/lib/algolia/mappers/article';
import { mapGlossaryTermToAlgolia } from '@/lib/algolia/mappers/glossary-term';
const algolia = algoliasearch(process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_INDEXER_KEY!);
const indexedTypes = {
article: { index: 'articles', map: mapArticleToAlgolia },
glossaryTerm: { index: 'glossary_terms', map: mapGlossaryTermToAlgolia },
} as const;
export async function POST(req: Request) {
const raw = await req.text();
if (!verifySignature(req, raw)) {
return NextResponse.json({ ok: false, error: 'invalid signature' }, { status: 401 });
}
const event = JSON.parse(raw);
const topic = req.headers.get('x-contentful-topic') ?? '';
const contentTypeId = event.sys?.contentType?.sys?.id;
const config = indexedTypes[contentTypeId as keyof typeof indexedTypes];
if (!config) {
return NextResponse.json({ ok: true, skipped: true, reason: 'not indexed' });
}
const isDelete = topic.endsWith('Entry.unpublish') || topic.endsWith('Entry.delete');
const locales = await getActiveLocales(); // ['en-US', 'fr-FR', ...]
if (isDelete) {
const objectIDs = locales.map((l) => `${event.sys.id}-${l}`);
await algolia.deleteObjects({ indexName: config.index, objectIDs });
return NextResponse.json({ ok: true, deleted: objectIDs.length });
}
// Fetch full entry with linked references resolved
const entry = await getEntryWithRefs(event.sys.id, { include: 4 });
const records = locales.map((l) => config.map(entry, l)).filter(Boolean);
await algolia.saveObjects({ indexName: config.index, objects: records });
return NextResponse.json({ ok: true, count: records.length });
}
function verifySignature(req: Request, body: string): boolean {
const secret = process.env.CONTENTFUL_WEBHOOK_SECRET;
if (!secret) return false;
const provided = req.headers.get('x-contentful-signature');
if (!provided) return false;
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(provided),
Buffer.from(expected.length === provided.length ? expected : '')
);
}
async function getActiveLocales() {
// Cache in a module-level map for the function's warm life
return ['en-US', 'fr-FR'];
}
// scripts/backfill.ts — run via `pnpm tsx scripts/backfill.ts`
import { algoliasearch } from 'algoliasearch';
import { getCdaClient } from '@/lib/contentful/server';
import { mapArticleToAlgolia } from '@/lib/algolia/mappers/article';
const algolia = algoliasearch(APP_ID, ADMIN_KEY);
const cda = getCdaClient();
const locales = ['en-US', 'fr-FR'];
async function backfillArticles() {
let skip = 0;
const limit = 100;
let total = Infinity;
const allRecords: any[] = [];
while (skip < total) {
const page = await cda.getEntries({
content_type: 'article',
skip,
limit,
include: 4,
locale: '*', // all locales in one fetch
});
total = page.total;
for (const entry of page.items) {
for (const locale of locales) {
allRecords.push(mapArticleToAlgolia(entry, locale));
}
}
skip += limit;
}
await algolia.replaceAllObjects({
indexName: 'articles',
objects: allRecords,
batchSize: 1000,
});
}
backfillArticles().catch(console.error);
Run via:
POST /api/algolia/backfill?type=article).Editorial workflows need draft content visible in preview environments. Pattern:
articles, glossary_terms) — populated from CDA (published only).articles_preview, glossary_terms_preview) — populated from Preview API (drafts + published), in a separate Algolia application (e.g., slalom-{client}-staging).Entry.save (drafts), to the production indexer on Entry.publish.Config:
// lib/algolia/index-name.ts
export function getIndexName(type: string) {
return process.env.NEXT_PUBLIC_CONTENTFUL_PREVIEW_MODE === 'true'
? `${type}s_preview`
: `${type}s`;
}
Don't mix preview and production records in one index. Preview content leaks into search, and editors panic.
For Contentful spaces with field-level localization (the typical case):
objectID = ${sys.id}-${locale}.locale attribute.locale:{currentLocale}.For locales with fallback chains (German falls back to English):
_locale_actual attribute showing where the content actually came from (for debugging)._localized boolean (true if all fields are in locale; false if some fell back).For very large locale sets (>10), consider one index per locale (articles_en_us, articles_fr_fr). Operational overhead is higher; per-index settings let you tune relevance per language.
Topics (Article, GlossaryTerm, Person, Product) → Algolia records.
Assemblies (Page, PageSection, LandingPage) → usually not indexed; they're navigation, not search hits.
Exceptions:
LandingPage as its own type with title, summary, URL.When indexing assemblies:
body_plaintext field.type: 'page' to differentiate from topics.Document the runbook in the engagement's Runbooks/ folder.
# Contentful → Algolia Integration: [Engagement]
## Choice: Marketplace app | Custom indexer | Hybrid
- Justification
## Indices
| Contentful type | Algolia index | Locales | Volume estimate |
|-----------------|---------------|---------|-----------------|
| article | articles | en-US, fr-FR | ~200 entries |
| glossaryTerm | glossary_terms| en-US | ~50 entries |
## Preview vs. delivery
- Production: app `slalom-{client}-prod`, indices: articles, glossary_terms
- Preview: app `slalom-{client}-staging`, indices: articles_preview, glossary_terms_preview
- Routing: env-aware client
## Webhook config
- Topics
- Filters
- Signature secret
## Mapping
- Per-type mapping module
- Linked-reference resolution depth
- Computed fields source
## Backfill
- Manual endpoint
- Scheduled cron
## Observability
- Logging
- Alert on indexer failure
- Drift metric (records-in-source vs records-in-index)
## Roles
- Who maintains the indexer code
- Who updates Algolia settings
- Who handles editor-facing issues
## Open questions
include cap and your record-size budget both bite.contentful-content-model, contentful-webhooks, contentful-graphql in the contentful plugin.algolia-index-design, algolia-relevance-tuning, algolia-indexing-pipeline.algolia-api-keys-security.algolia-mcp-cli.../../references/integrations-map.md../../references/algolia-foundations.mdcontentful (full Contentful surface)