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).
How 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.**
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)Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub bpainter/composable-dxp-claude-marketplace --plugin algolia