API endpoint
Request parameters, response shape, validation rules, caching and injection safety.
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
}
]
}
urlis absolute — the route resolves the stored relative path against the request origin.tagsis a space-separated string, not an array. It's one FTS5 column.dateisYYYY-MM-DD, or''for undated documents.scoreis bm25 × the recency multiplier. Lower (more negative) is a better match, and results arrive already sorted.
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:
- Quote characters (
",',`) are replaced with spaces. - The result is split on whitespace and capped at
maxTermsterms. - 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. - 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:
- only
q,limit,offsetandtype(in that fixed order) contribute qis lowercased, since FTS5 matching is case-insensitive anywaylimitis the clamped value, not the raw one
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.