Repo

The integration injects a server-rendered GET endpoint at apiRoute (default /api/search).

GET /api/search?q=<query>&limit=<n>&offset=<n>&type=<type>

Parameters

Parameter Type Default Notes
q string '' The query. Under 2 characters returns an empty result set, not an error
limit int 10 Clamped to maxLimit. Non-integer or < 1 returns 400
offset int 0 Negative or above maxOffset returns 400
type string Must be one of the types from your sources, otherwise 400

Anything else in the query string is ignored, and doesn't affect the cache key.

Response

{
  "query": "css grid",
  "results": [
    {
      "title": "CSS Grid",
      "url": "https://example.com/notes/css-grid",
      "type": "note",
      "date": "2023-01-20",
      "tags": "css layout",
      "emoji": "🍱",
      "snippet": "…display: <mark>grid</mark>; <mark>grid</mark>-template…",
      "score": -7.71
    }
  ]
}

Errors

{ "error": "Invalid limit" }
Status Cause
400 Bad limit, offset, type, or a query over maxQueryLength
500 The configured D1 binding isn't present on the runtime environment

The 400 for an invalid type names the valid ones:

{ "error": "Invalid type. One of: blog, note, page" }

A 500 almost always means either the binding is missing from your wrangler config, or you're running astro dev without platformProxy: { enabled: true } on the Cloudflare adapter.

Query handling

Raw user input never reaches FTS5. Each query is converted into a safe MATCH expression:

  1. Quote characters (", ', `) are replaced with spaces.
  2. The result is split on whitespace and capped at maxTerms terms.
  3. Every term is wrapped in double quotes, which makes FTS5 treat it as a literal — AND, OR, NOT, NEAR(), * and - all lose their operator meaning.
  4. The last term gets a * suffix, so it prefix-matches.
import { toFtsQuery } from "astro-d1-search";

toFtsQuery("css grid"); // '"css" "grid"*'
toFtsQuery("cats OR dogs"); // '"cats" "OR" "dogs"*'
toFtsQuery("gri"); // '"gri"*'

That trailing * is why search-as-you-type works with no extra effort: a half-typed word still matches.

Everything else is a bound parameter — the query, the type filter, the limit and offset. The only values interpolated into SQL are numbers from your own resolved config (bm25 weights, the recency window, the snippet size).

Snippets and HTML

snippet contains <mark> tags around the matched terms, generated by FTS5's snippet() function. It's built from your indexed content, so rendering it as HTML (set:html, dangerouslySetInnerHTML) is safe when that content is your own markdown.

If you index content you don't control — user submissions, third-party feeds — escape everything except the <mark> tags, or render the snippet as plain text.

Caching

Responses are cached at the Cloudflare edge under a canonicalised key:

So ?q=CSS%20Grid, ?q=css+grid and ?q=css%20grid&utm_source=x all hit the same cache entry. Repeated queries are served from the edge without touching D1. The cache write happens inside ctx.waitUntil() where available, so it doesn't delay the response.

Cache lifetime is cacheMaxAge (default 5 minutes) — which is also, in practice, how long a freshly-pushed index takes to be fully visible to everyone.

Consuming it externally

With the default cors: true, anything can call the endpoint: a Raycast extension, a CLI, another site, a bot. That's usually what you want for a public content site, since the endpoint exposes nothing your HTML doesn't. If it isn't, see securing the endpoint.

Theme

Copy this CSS to your project: