Pluggable Search (pro, off by default)
Cross-content-type, relevance-ranked search via Meilisearch or Algolia —
closes the gap where only blog posts are searchable via the default
WPGraphQL search argument
(GraphQL Reference). Off by
default — with SEARCH_PROVIDER unset, getSearchProvider() returns
null and every call site falls back to today's blog-only search
unchanged.
Adapter shape
interface SearchProvider {
search(query: string, options?: { limit?: number; filter?: string }): Promise<SearchResult[]>;
index(documents: SearchDocument[]): Promise<void>;
deleteFromIndex(ids: string[]): Promise<void>;
}
Deliberately minimal — 3 methods, no provider-specific features (facets, typo tolerance, synonyms, etc.) routed through the shared interface. Those stay provider-specific, configured via that provider's own env vars. This keeps swapping providers trivial at the cost of not exposing every provider's advanced features uniformly — a stated v1 tradeoff.
Indexed content types: post, headlessProduct, teamMember, faq,
pricingPlan (the same 4 CPTs documented in
Content Model, plus standard blog posts).
Setup
Option A — Meilisearch (self-hosted)
docker run -d -p 7700:7700 -v $(pwd)/meili_data:/meili_data \
getmeili/meilisearch:latest \
meilisearch --master-key="your-master-key"
Next.js env vars:
SEARCH_PROVIDER="meilisearch"
NEXT_PUBLIC_MEILISEARCH_HOST="http://localhost:7700"
NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY="<a search-only key, not the master key>"
MEILISEARCH_ADMIN_KEY="your-master-key"
MEILISEARCH_INDEX="headlesscore_content" # optional, defaults to this
Generate a search-only key via Meilisearch's /keys API — never expose
the master/admin key client-side (NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY
must be a search-only key).
Option B — Algolia (hosted)
Create an app + index in the Algolia dashboard, then:
SEARCH_PROVIDER="algolia"
NEXT_PUBLIC_ALGOLIA_APP_ID="your-app-id"
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY="<the search-only API key>"
ALGOLIA_ADMIN_KEY="<the admin API key>"
ALGOLIA_INDEX="headlesscore_content" # optional, defaults to this
Same rule: NEXT_PUBLIC_ALGOLIA_SEARCH_KEY must be a search-only key
(Algolia scopes keys by permission — the admin key must never ship to the
client).
WordPress side
Headless Core → Settings → Search (requires a valid pro license):
- Next.js Search Webhook URL — your frontend's base URL, e.g.
https://yoursite.com(documents POST to{this}/api/search) - Search Index Secret — matches
SEARCH_INDEX_WEBHOOK_SECRETbelow
SEARCH_INDEX_WEBHOOK_SECRET="generate-with-openssl-rand--base64-32"
Click Reindex All Content once, after setup, to backfill the index with everything already published.
How indexing works
save_post(priority 21,inc/search.php) — a separate hook from cache revalidation (priority 20,functions.php) so a search-indexing failure or timeout can never block or delay ISR cache busting.- Publishing a post/product/team member/FAQ/pricing plan pushes it to the index (non-blocking, fire-and-forget — a few seconds of staleness is fine for search).
- Unpublishing/trashing removes it from the index.
before_delete_postalso removes it (in case a post is deleted without first being unpublished).- "Reindex All Content" batches every published item across all 5 types, 50 documents per request, blocking (so the admin sees a real "N items reindexed" result).
Frontend usage
GET /api/search?q=your+query&limit=20&filter=type%3Dpost
Returns { results: SearchResult[] }, or 501 with an explanatory
message if SEARCH_PROVIDER is unset/misconfigured (deliberately not a
silently-empty result — a misconfigured deployment should be debuggable).
Search analytics
Handled differently per provider rather than through the shared adapter interface (matches the "provider-specific features stay provider-specific" tradeoff above):
- Algolia —
src/lib/search/algolia.ts'ssearch()sendsanalytics: trueon every query. Algolia's own dashboard then shows popular searches, no-results queries, and click-through, with zero extra infrastructure. - Meilisearch — the self-hosted OSS edition has no built-in analytics,
so
/api/search'sGEThandler logs each query WP-side instead: after returning results, it callsafter(() => logSearchQuery(query, count))fromnext/server(runs post-response — never delays the visitor's results), which calls alogSearchQueryGraphQL mutation (inc/search.php) writing to a private Search Queries CPT (wp-admin → Headless Core → Search Queries). This mutation is intentionally unauthenticated, same risk tier assubmitContactForm— worst-case abuse is log clutter, not cost or data exposure.
Deferred (not in v1)
- Faceted search beyond a single
filterstring. - Meilisearch query log has no wp-admin reporting UI — entries are browsable as raw CPT posts only, no aggregated "top queries" view.
- Incremental deindex is
save_post/before_delete_postonly — status transitions outside a normal save (e.g. bulk actions in some edge cases) may not always fire these hooks; if the index ever drifts, "Reindex All Content" fixes it. - Queued/batched indexing at scale. "Reindex All" is a synchronous
admin-triggered loop — fine for typical site sizes, not designed for
tens of thousands of posts (same caveat class as
src/lib/rateLimit.ts's "not for scale" note).