N
HeadlessCoreHeadless Core
Back to Home

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_SECRET below
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_post also 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):

  • Algoliasrc/lib/search/algolia.ts's search() sends analytics: true on 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's GET handler logs each query WP-side instead: after returning results, it calls after(() => logSearchQuery(query, count)) from next/server (runs post-response — never delays the visitor's results), which calls a logSearchQuery GraphQL 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 as submitContactForm — worst-case abuse is log clutter, not cost or data exposure.

Deferred (not in v1)

  • Faceted search beyond a single filter string.
  • 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_post only — 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).