Skip to content

Search

Requires a license with the search feature. See pricing.

Search finds entries by the words in them, ranks the best matches first and marks where each match is. It runs in your database with nothing else to install, and can move to Elasticsearch or Meilisearch for large catalogs. You choose which content types your public site may search, and you can see what readers look for.

Searching from the admin console instead? Search and organization covers the console side.

PartWhat it does
IndexEvery entry's title, body and tags, kept up to date as entries change. On PostgreSQL the tags are searched too. On MySQL and SQL Server only the title and the body are.
Full-text searchRanks entries by how well they match, with filters, facets and marked snippets. Admins search every status.
Instant searchTitles that match what the reader has typed so far, published entries only.
Public searchPublished entries of the content types you list, for anyone, on your tenant's own host.
Synonyms and weightsWiden a search to other words, and count a title match above a body match.
AnalyticsEach search's text, result count and time.

This needs at least one published entry, such as the one the content lifecycle steps create. You need a token in TOKEN for an admin or super_admin. The quickstart shows how to get one.

  1. Search for a word in the entry:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/search \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"q": "hello", "highlight": true, "facets": ["tags"], "limit": 10}'

    The answer lists results with each entry's rank and snippets, the total, and the facets you asked for.

  2. Try autocomplete with the first letters:

    Terminal window
    curl "http://localhost:3001/api/admin/search/instant?q=hel&limit=5" \
    -H "Authorization: Bearer $TOKEN"
    { "results": [{ "entry_id": "ad39fb3d-eb5a-47ce-9cd5-e7d421acaa98", "title": "Hello", "slug": "hello", "schema": "post" }], "query": "hel", "took_ms": 0 }
  3. Add a synonym group:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/search/synonyms \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"name": "Greetings", "base_term": "hi", "synonyms": ["hello"]}'

    The answer is 201 with the group. A search for hi now finds the entry too.

  4. Read the analytics:

    Terminal window
    curl http://localhost:3001/api/admin/search/analytics \
    -H "Authorization: Bearer $TOKEN"
    { "total_searches": 2, "unique_queries": 2, "avg_result_count": 1, "avg_duration_ms": 3, "top_queries": [{ "query_text": "hello", "count": 1 }], "zero_result_pct": 50 }
  5. Open Search in the admin console. The synonym is listed under Synonyms, and Last 30 days counts your searches.

POST /api/admin/search takes these fields. GET /api/admin/search takes the same ones as query parameters, with tags and facets comma-separated and highlight=true.

FieldMeaningDefault
qThe search textnone
schemaLimit to one content typeall
statusLimit to draft, published or another statusall
tagsEntries with any of these tagsnone
published_after, published_beforeRFC 3339 timesnone
facetsUp to three of schema, status and tags to countnone
highlightReturn snippets with matches markedfalse
limit, offsetPaging. limit is at most 20020, 0

At least one of q, schema, status or tags is required.

A full answer
{
"results": [
{
"entry_id": "8f14e45f-ea2c-4b1d-9f3a-2c1b0e7d6a55",
"schema": "article",
"tenant_id": "default",
"slug": "pricing-update-2026",
"title": "Pricing update for 2026",
"body": { "text": "Our prices change on 1 November." },
"meta": {},
"status": "published",
"tags": ["news"],
"published_at": "2026-09-30T08:00:00Z",
"created_by": "12dd433d-c110-4809-b476-12ef360afe7f",
"created_at": "2026-09-29T15:00:00Z",
"updated_at": "2026-09-30T08:00:00Z",
"rank": 0.61,
"snippets": { "title": "<mark>Pricing</mark> update for 2026" }
}
],
"total": 1,
"limit": 10,
"offset": 0,
"facets": { "tags": [{ "value": "news", "count": 1 }] },
"query": "pricing"
}

Facet counts follow q alone, not the other filters, and are kept for 30 seconds. They are left out when nothing matched.

GET /api/admin/search/instant takes q, an optional schema and limit, which is 10 by default and at most 50.

List the content types anyone may search:

Terminal window
curl -X PUT http://localhost:3001/api/admin/search/public-schemas \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"schemas": ["article", "product"]}'

The answer repeats the list. Send an empty list to make search admin only again. Your site then calls the public route with no credential, on the tenant's own host:

Terminal window
curl "https://acme.example.com/api/v1/search/article?q=pricing&limit=10"
{
"hits": [
{
"id": "8f14e45f-ea2c-4b1d-9f3a-2c1b0e7d6a55",
"schema": "article",
"slug": "pricing-update-2026",
"title": "Pricing update for 2026",
"tags": ["news"],
"published_at": "2026-09-30T08:00:00Z",
"snippets": { "title": "<mark>Pricing</mark> update for 2026" }
}
],
"total": 1,
"limit": 10,
"offset": 0,
"query": "pricing",
"took_ms": 4
}
  • Public search returns published entries only, and only the fields a result needs: no body and no author. Snippets are always on.
  • q is required and at most 256 characters. tags filters by up to 10 comma-separated tags, and any beyond 10 are dropped.
  • limit is 10 by default and at most 50, and offset is at most 1000.
  • A content type that is not public answers 404, the same as one that does not exist.
  • Browsers may cache a result for 30 seconds.

In the console, the Public search card on the Search page sets the same list with Anyone may search.

Terminal window
curl -X POST http://localhost:3001/api/admin/search/synonyms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Computers", "base_term": "laptop", "synonyms": ["notebook", "ultrabook"]}'

A search whose whole text is the base term also matches its synonyms. It works one way: a search for notebook does not match laptop. A search uses the text and the first 7 synonyms of the group.

The text is looked up as typed, then in lower case, so Laptop finds a group stored as laptop but laptop does not find one stored as Laptop. Store base terms in lower case. Synonyms apply on the database backend and on Elasticsearch. On Meilisearch, set synonyms in the index settings.

Terminal window
curl -X PUT http://localhost:3001/api/admin/search/ranking \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"schema_name": "article", "title_weight": 1.0, "body_weight": 0.3, "tag_weight": 0.5}'

A search uses the weights saved for its content type, then the ones saved under schema_name *, then the defaults: title 1.0, body 0.4, tags 0.2. A negative weight counts as 0, and all three at 0 count as the defaults. GET /api/admin/search/ranking?schema=article answers the weights saved under that exact name, or the defaults when none are, even if a * row exists. The console's Ranking weights card edits the * row.

BackendHow weights apply
PostgreSQLTitle, body and tags are weighted separately.
MySQL and SQL ServerA title match adds the title weight and a body match the body weight. Tags are not searched, so tag_weight has no effect.
ElasticsearchField boosts on title, body and tags.
MeilisearchNot applied. Meilisearch uses its own ranking rules.

boost_rules are saved and returned, but no backend applies them.

GET /api/admin/search/analytics answers the total searches, unique queries, average result count and time, the top 20 queries and the share that found nothing. By default it covers the last month, and ?since= sets the start.

Your front end can report a search it ran with POST /api/admin/search/analytics/log and a result the reader clicked with POST /api/admin/search/analytics/click. Both answer 202. A click is kept only when it matches a logged search with the same query_text and session_id, and the summary does not count clicks.

Set SEARCH_PROVIDER and the settings for that backend below, restart, then rebuild the index once with POST /api/admin/search/reindex, which needs super_admin:

Terminal window
curl -X POST http://localhost:3001/api/admin/search/reindex \
-H "Authorization: Bearer $TOKEN"

The answer reports what was indexed, such as "message": "elasticsearch: 1840 indexed, 0 skipped, 0 errors". After that, creates, updates and deletes reach the external index within about five seconds. On the database backend a rebuild is rarely needed, and answers rebuilt: <n> indexed, ... on PostgreSQL.

VariableWhat it doesDefault
SEARCH_PROVIDERnative, elasticsearch or meilisearch.native
SEARCH_ES_URLSElasticsearch node URLs, comma-separated.http://localhost:9200
SEARCH_ES_INDEX_PREFIXPrefix for Elasticsearch index names.lyeve
SEARCH_ES_API_KEYElasticsearch API key.unset
SEARCH_ES_USERNAME, SEARCH_ES_PASSWORDElasticsearch basic auth.unset
SEARCH_MEILI_URLMeilisearch URL.http://localhost:7700
SEARCH_MEILI_INDEX_PREFIXPrefix for Meilisearch index names.lyeve
SEARCH_MEILI_API_KEYMeilisearch API key.unset
SEARCH_REINDEX_BATCH_SIZEChanges sent to the external index per batch.200

Search needs a license with search. If you set LYEVE_PLUGINS to choose which features start, include search in it. See licensing and tiers.

The admin routes take a signed-in session. An admin token cannot call them.

Search routes
MethodPathRolePurpose
GET/api/v1/search/{schema}publicSearch a public content type.
POST, GET/api/admin/searchadminFull-text search.
GET/api/admin/search/instantadminAutocomplete.
GET, PUT/api/admin/search/public-schemasadminRead or set the public content types.
GET, POST/api/admin/search/synonymsadminList or add synonym groups.
PUT, DELETE/api/admin/search/synonyms/{id}adminChange or remove a synonym group.
GET, PUT/api/admin/search/rankingadminRead or set ranking weights.
DELETE/api/admin/search/ranking/{id}adminRemove saved weights.
POST/api/admin/search/analytics/logadminReport a search.
POST/api/admin/search/analytics/clickadminReport a click.
GET/api/admin/search/analyticsadminAnalytics summary.
POST/api/admin/search/reindexsuper_adminRebuild the index.

Without a license that carries search, the routes answer 404. A license that lapses while the instance runs makes them answer 402 payment_required.

Every error these routes return
StatusMessageCause
400at least one search criterion is required (q, schema, status, or tags)POST with no q, schema, status or tags. GET answers the short form.
400q is requiredA public search had no text.
400q is too long or contains invalid charactersA public search over 256 characters.
400limit must be a positive number, offset must be zero or moreBad paging on a public search.
400base_term is required, at least one synonym is required, invalid synonym IDA synonym request is incomplete.
400schema_name is requiredRanking weights named no content type.
400entry_id is required, query_text is requiredAn analytics report is incomplete.
402payment_requiredThe license lapsed while the instance ran.
403reindex requires super_admin roleA reindex by someone else.
404not foundThe content type is not public.
404synonym not found, ranking config not foundUnknown id.
422"<name>" is not a schema nameA public content type name is not valid.