This guide walks through a movies-shaped example (genre, year, rating, director) that stays on the common Scrypath.search/3 path with facets:, facet_filter:, and URL-friendly handle_params/3. The patterns mirror the library’s own contract tests so prose and code stay aligned as APIs evolve.
Overview
Faceted search combines full-text search with facet distributions (counts per attribute value) and optional facet filters that narrow results. Scrypath keeps the contract explicit:
- Declarations live on the schema (
faceting:aligned withfilterable:). - Runtime options on
Scrypath.search/3include:facetsand:facet_filter. - Meilisearch wire keys (
facetDistribution,facetStats) decode into%Scrypath.SearchResult.Facets{}.
LiveView owns assigns, loading, and URL state. Your context still owns repo access, hydration options, and the call to Scrypath.search/3.
Prerequisites
- Read
guides/relevance-tuning.mdfor how schemasettings:maps into Meilisearch index settings before you tune ranking alongside facets. - A running Meilisearch is not required to follow the patterns — tests in the library use
FakeBackendandReq.Test-style fixtures for deterministic bodies.
Declare facets on the schema
Use the same movie shape as the tests: filterable: must be a superset of faceting.attributes:.
defmodule MyApp.Movies.Movie do
use Ecto.Schema
use Scrypath,
fields: [:title, :genre, :year, :rating, :director],
filterable: [:genre, :year, :rating, :director],
faceting: [
attributes: [:genre, :year, :rating, :director],
max_values_per_facet: 100
]
schema "movies" do
field :title, :string
field :genre, :string
field :year, :integer
field :rating, :float
field :director, :string
end
endIf you request a facet not listed in faceting.attributes, Scrypath.search/3 returns {:error, {:unknown_facet, attr}}. The user-facing dev copy for that situation is: That attribute is not declared on this schema's faceting: list.
Hierarchical facets
Meilisearch represents hierarchical menus as multiple filterable facet attributes rather than a nested JSON tree under one key. A common pattern uses dotted paths such as "categories.lvl0" and "categories.lvl1" that align with flat facetDistribution maps per attribute in search responses.
Opt-in: set nested_facet_paths: true under faceting: before declaring dotted atoms such as :"categories.lvl0" in faceting.attributes. Schemas that omit this flag keep the same flat-only behavior as earlier releases.
Sugar: optional hierarchy: [base: :categories, depth: 2] expands into :"categories.lvl0" and :"categories.lvl1" style names so you list the base field once; expanded names still must appear in filterable:.
Counts and filters: drilling down typically applies filters AND between facet attributes (conjunctive across the per-level fields) while values within one attribute stay disjunctive when you pass a list to facet_filter: for that field.
Wire shape: facetDistribution returns one map per declared attribute string key, not a nested hierarchy object. Keys in result.facets.distribution use the same atoms you pass to facets: and declare under faceting.attributes.
Wildcards remain mistakes: declaring :* or dotted names outside the supported single-dot lvlN suffix pattern still fails fast at compile time.
Disjunctive facet counts
Facet UX often combines OR within the same facet field (for example two genres) with AND across different facet fields (for example genre OR plus a specific year). Scrypath.Meilisearch.Query already encodes that filter shape; this section is about facetDistribution counts.
A single search response always intersects facet refinements with the hit set. Bucket counts therefore reflect what Meilisearch computed after every active facet filter — not “unrefined OR-group” counts from that response alone.
Operators who want disjunctive bucket counts follow Meilisearch’s multi-search recipe: keep the tightened main query for hits, issue auxiliary searches that drop each disjunctive group’s refinements for that group’s distribution (often limit: 0), then merge raw facetDistribution maps with Scrypath.Facets.Disjunctive.merge_distributions/2 before decoding into %Scrypath.SearchResult.Facets{}.
Reference scenario Genre OR + year AND on the movies catalog: pass two genre values under one facet_filter: key (OR inside genre) and one year value under year (AND against the genre group). Hits and counts both narrow on the conjunctive document set until you add auxiliary queries for disjunctive count UX.
| Query role | What facetDistribution answers |
|---|---|
| Main search with all filters | Counts on the narrowed catalog (honest single search semantics). |
| Auxiliary per-field queries | Counts as if that facet group were relaxed, for merging via multi-search. |
Searching within a facet selection
Catalog UIs often show a facet bucket (for example Genre → Action) and need the next search to stay inside that bucket while still using the normal Scrypath.search/3 options for sort, pagination, non-facet filter:, and additional facet_filter: keys on other attributes.
Use Scrypath.search_within_facet/4, passing {facet_attribute, value} as the third argument. The library merges that bucket into the effective facet_filter: and runs one validated search on the same Meilisearch /search path as Scrypath.search/3, so operators still see the [:scrypath, :search] span with extra metadata that marks the call as scoped.
For the general full-text path without a positional bucket, keep using Scrypath.search/3.
Composing facet filters with scoped search
AND semantics apply across filter:, the locked bucket, and any other facet_filter: keys: a hit must satisfy every constraint Meilisearch composes from filter plus facetFilters.
If facet_filter: already contains the same facet attribute as the bucket, the library raises ArgumentError — do not duplicate the same attribute from URL params and LiveView assigns (a common footgun). Prefer one source of truth: either pass the refinement only in facet_filter: with search/3, or lock the bucket with search_within_facet/4 and drop that key from facet_filter:.
Dotted hierarchical facet atoms follow the same one-attribute rule: the bucket attribute must not appear twice across facet_filter: and the positional tuple.
search_within_facet: does not change disjunctive count mechanics — that remains the separate multi-search merge story documented under Disjunctive facet counts above.
Primary path: handle_params + URL sync
Recommended: normalize query + facet params in handle_params/3, then call your context with a keyword list that mirrors what you will pass to Scrypath.search/3.
def handle_params(params, _uri, socket) do
q = Map.get(params, "q", "")
genres = parse_genres(params["genre"])
years = parse_int_list(params["year"])
facet_filter =
[]
|> maybe_put(:genre, genres)
|> maybe_put(:year, years)
{:noreply, run_search(socket, q, facet_filter)}
endUse push_patch/2 or <.link navigate={~p"/movies?#{params}"}> so refresh and deep links restore the same facet state. Example URLs in this guide use fictional hosts such as https://example.com only.
Sidebar checklist (genre + director)
Render facet buckets from result.facets.distribution (not raw Meilisearch JSON in templates). Checkbox groups map cleanly to disjunctive within field semantics for facet_filter::
example_facet_filter = [genre: ["Horror", "Sci-Fi"]]Layer: UI checklist rows use gap-2 (8px) vertical rhythm between rows for consistent spacing.
Chip row (active filters)
Active facet_filter entries should render as removable chips between the search rail and the results list. Each chip exposes aria-label Remove {facet name}: {value} (for example Remove genre: Horror).
Primary CTA copy is locked as Search catalog — it submits the text query and applies the current facet state to Scrypath.search/3.
Numeric range (rating)
Use result.facets.stats for numeric min/max labels when present, then issue range filters with the common filter operators:
Scrypath.search(MyApp.Movies.Movie, "space",
backend: MyApp.SearchBackend,
facets: [:rating],
facet_filter: [rating: [gte: 3.0, lte: 5.0]]
)Search-within-facet (director list)
For long director lists, add a text input that filters rows client-side (LiveView assign only). This does not call the Meilisearch facet-search API (deferred on the roadmap); it is assign-filter only for v1.3.
Loading and errors
While Scrypath.search/3 is in flight, show Searching… on the primary CTA (disabled) and aria-busy="true" on the results region.
On {:error, reason}, show Search could not complete. with inspect(reason) in monospace, then Retry search and Remove the filter you added last as actions.
Empty results use:
- Heading: No movies match these filters
- Body: Try removing one filter or shortening your search. Bucket counts update as you change filters.
Destructive reset copy: Clear all filters with confirmation Reset genre, year, rating, and director? — confirm Reset filters, cancel Keep filters.
Progressive disclosure: handle_event only
You can prototype with handle_event/3 alone for classroom demos. Add an explicit disclaimer: bookmarking and refresh will not restore facet state until you move the same parameters through handle_params/3.
Anti-pattern appendix
Single index; bands are API, Meilisearch, then UI. Each entry lists Layer → mistake → consequence → why → do instead.
API
API — Wildcard facet attributes
Layer: API
The mistake: Declaring :* in faceting.attributes, or dotted atoms without nested_facet_paths: true, or dotted shapes that do not follow the single-dot lvlN suffix pattern.
User-visible consequence: Compile error or confusing ArgumentError instead of a searchable index.
Why: Wildcards stay unsupported. Dotted Meilisearch-style hierarchical paths require the explicit opt-in and the documented lvlN suffix shape so facet atoms and index settings stay predictable.
Do instead: List explicit flat atoms, or follow Hierarchical facets above with nested_facet_paths: true and supported lvlN paths that are also filterable:.
See also: Schema section above.
API — Facet filter as raw string
Layer: API
The mistake: Passing a Meilisearch filter string into facet_filter:.
User-visible consequence: Validation rejects the call or you bypass Scrypath’s field checks.
Why: The common path only accepts keyword-shaped filters so keys can be checked against faceting.attributes.
Do instead: Use keyword lists and structured range maps; only bypass the common Scrypath.search/3 contract when you intentionally issue raw Meilisearch HTTP requests yourself.
See also: Scrypath.search/3 docs.
API — Unknown facet atom
Layer: API
The mistake: Adding :studio to :facets without declaring it on the schema.
User-visible consequence: {:error, {:unknown_facet, :studio}} from Scrypath.search/3.
Why: Facets must be declared explicitly on the schema so Scrypath can validate requests against faceting.attributes.
Do instead: Extend faceting: [attributes: ...] and managed settings before requesting the facet.
Meilisearch
Meilisearch — Ignoring AND between filter and facetFilters
Layer: Meilisearch
The mistake: Assuming filter replaces facetFilters in the JSON body.
User-visible consequence: Results still narrowed by a facet you thought you cleared.
Why: Meilisearch ANDs filter and facetFilters together.
Do instead: Clear both layers when resetting UI state; consult Scrypath.Meilisearch.Query payload helpers.
Meilisearch — Expecting facet-search hits from facets:
Layer: Meilisearch
The mistake: Treating facetDistribution as a second search hit list.
User-visible consequence: UI shows counts but no “facet value documents”.
Why: Distribution is counts per bucket, not nested documents.
Do instead: Keep document hits in result.hits and counts in result.facets.
Meilisearch — Counts ignore my OR group
Layer: Meilisearch
The mistake: Expecting one response’s facetDistribution to show counts as if an OR-selected facet group were ignored for counting purposes.
User-visible consequence: Operators think Scrypath “lost” their OR semantics when bucket numbers shrink with refinements.
Why: Meilisearch intersects counts with the same filtered hit set the UI already narrowed.
Do instead: Reread Disjunctive facet counts above and implement the documented multi-search merge path when you need disjunctive count UX.
UI
UI — Mutating URL only in handle_event
Layer: UI
The mistake: Patching assigns without syncing the browser URL.
User-visible consequence: Shared links miss facet state.
Why: Deep links are a first-class expectation for faceted catalog UX.
Do instead: Mirror params in handle_params/3 and push_patch/2.
UI — Hiding loading state on slow networks
Layer: UI
The mistake: Leaving buttons enabled while a search is in flight.
User-visible consequence: Double submits and conflicting results.
Why: Operators cannot tell whether a slow facet toggle applied.
Do instead: Disable Search catalog and mark the results region aria-busy="true" until the tuple returns.
UI — Silent unknown facet failures
Layer: UI
The mistake: Dropping {:error, {:unknown_facet, _}} on the floor.
User-visible consequence: Empty panes with no explanation.
Why: The {:error, {:unknown_facet, _}} tuple is the signal that a facet was requested without a matching faceting: declaration.
Do instead: Surface That attribute is not declared on this schema's faceting: list. beside the control that triggered the request.
Fixture cross-reference
The compile-checked fixture Scrypath.TestSupport.Docs.PhoenixExampleCase.FacetedBrowseLive mirrors the handle_params flow without importing your application code—useful when extending library tests that keep this guide and ExDoc snippets in sync.
See also
guides/phoenix-liveview.md— context boundary for LiveView.Scrypath.search/3,Scrypath.schema_faceting/1, andScrypath.Meilisearch.Query.to_payload/1in ExDoc for the exact option keys and wire mapping.