Search Integration
Stacks.js provides flexible search capabilities from simple database queries to full-text search engines.
Database Search
Basic Search
// Simple LIKE search
const users = await User
.where('name', 'like', `%${query}%`)
.orWhere('email', 'like', `%${query}%`)
.get()
// Case-insensitive search
const posts = await Post
.whereRaw('LOWER(title) LIKE ?', [`%${query.toLowerCase()}%`])
.get()
Full-Text Search (PostgreSQL)
// Using PostgreSQL full-text search
const results = await Post
.whereRaw(
"to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ?)",
[query]
)
.orderByRaw(
"ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', ?)) DESC",
[query]
)
.get()
Full-Text Search (MySQL)
// Using MySQL FULLTEXT index
const results = await Post
.whereRaw(
"MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)",
[query]
)
.orderByRaw(
"MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE) DESC",
[query]
)
.get()
SQLite FTS5
// Create virtual table for FTS
await DB.raw(`
CREATE VIRTUAL TABLE posts_fts USING fts5(
title,
content,
content=posts,
content_rowid=id
)
`)
// Search using FTS5
const results = await DB.raw(`
SELECT posts.*
FROM posts_fts
JOIN posts ON posts_fts.rowid = posts.id
WHERE posts_fts MATCH ?
ORDER BY rank
`, [query])
Searchable Trait
Making Models Searchable
// app/Models/Post.ts
import { Model, Searchable } from '@stacksjs/orm'
export default class Post extends Model {
use = [Searchable]
// Define searchable fields
static searchable = ['title', 'content', 'excerpt']
// Optional: Weight fields differently
static searchWeights = {
title: 'A', // Highest priority
excerpt: 'B', // Medium priority
content: 'C', // Lower priority
}
// Optional: Custom search configuration
static searchConfig = {
minLength: 3,
fuzzy: true,
highlight: true,
}
}
Using Searchable Models
// Search posts
const results = await Post.search('typescript tutorial')
// With additional filters
const results = await Post
.search('typescript')
.where('published', true)
.where('category_id', 5)
.get()
// Paginated search
const results = await Post
.search('typescript')
.paginate(20)
Search Drivers
Configuration
// config/search.ts
export default {
// Default search driver
default: 'database',
drivers: {
// Database driver (default)
database: {
driver: 'database',
},
// Meilisearch
meilisearch: {
driver: 'meilisearch',
host: process.env.MEILISEARCH_HOST || 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_KEY,
},
// Typesense
typesense: {
driver: 'typesense',
host: process.env.TYPESENSE_HOST || 'localhost',
port: 8108,
protocol: 'http',
apiKey: process.env.TYPESENSE_KEY,
},
// Algolia
algolia: {
driver: 'algolia',
appId: process.env.ALGOLIA_APP_ID,
apiKey: process.env.ALGOLIA_API_KEY,
},
// Elasticsearch
elasticsearch: {
driver: 'elasticsearch',
hosts: [process.env.ELASTICSEARCH_HOST || 'http://localhost:9200'],
username: process.env.ELASTICSEARCH_USER,
password: process.env.ELASTICSEARCH_PASSWORD,
},
}
}
Model-Specific Drivers
// app/Models/Product.ts
export default class Product extends Model {
use = [Searchable]
// Use a specific search driver for this model
static searchDriver = 'meilisearch'
static searchable = ['name', 'description', 'sku']
}
Meilisearch Integration
Setup
# Start Meilisearch with Pantry
pantry install meilisearch
pantry service start meilisearch
Index Configuration
// app/Models/Product.ts
export default class Product extends Model {
use = [Searchable]
static searchDriver = 'meilisearch'
static searchable = ['name', 'description', 'category', 'brand']
// Meilisearch-specific settings
static searchSettings = {
filterableAttributes: ['category', 'brand', 'price', 'in_stock'],
sortableAttributes: ['price', 'created_at', 'popularity'],
rankingRules: [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
],
}
// Transform data for search index
toSearchableArray() {
return {
id: this.id,
name: this.name,
description: this.description,
category: this.category?.name,
brand: this.brand?.name,
price: this.price,
in_stock: this.stock > 0,
}
}
}
Search with Filters
// Search with facets and filters
const results = await Product
.search('laptop')
.filter('category = "Electronics"')
.filter('price < 1000')
.filter('in_stock = true')
.sort('price:asc')
.get()
// Get facet counts
const { results, facets } = await Product
.search('laptop')
.facets(['category', 'brand'])
.get()
// facets = { category: { Electronics: 50, ... }, brand: { Apple: 20, ... } }
Typesense Integration
Setup
# Start Typesense with Docker
docker run -d -p 8108:8108 \
-v typesense-data:/data \
typesense/typesense:latest \
--api-key=xyz123 \
--data-dir=/data
Collection Schema
// app/Models/Article.ts
export default class Article extends Model {
use = [Searchable]
static searchDriver = 'typesense'
// Typesense schema definition
static searchSchema = {
fields: [
{ name: 'title', type: 'string' },
{ name: 'content', type: 'string' },
{ name: 'author', type: 'string', facet: true },
{ name: 'tags', type: 'string[]', facet: true },
{ name: 'published_at', type: 'int64' },
{ name: 'views', type: 'int32' },
],
default_sorting_field: 'published_at',
}
toSearchableArray() {
return {
id: String(this.id),
title: this.title,
content: this.content,
author: this.author?.name || '',
tags: this.tags?.map(t => t.name) || [],
published_at: this.published_at?.getTime() || 0,
views: this.views || 0,
}
}
}
Geo Search
// Search with geo-filtering (Typesense)
const results = await Store
.search('coffee')
.filterBy('location:(48.8566, 2.3522, 10 km)') // Paris, 10km radius
.get()
Indexing
Manual Indexing
# Index all searchable models
buddy search:index
# Index specific model
buddy search:index Post
# Clear and reindex
buddy search:flush Post
buddy search:index Post
Automatic Indexing
// Models are indexed automatically on save/delete
const post = await Post.create({
title: 'New Post',
content: 'Content here...',
})
// Automatically indexed
await post.update({ title: 'Updated Title' })
// Index updated
await post.delete()
// Removed from index
Queue Indexing
// config/search.ts
export default {
// Queue index operations for better performance
queue: true,
queueConnection: 'redis',
}
// Index operations are now queued
// Great for bulk imports
Bulk Indexing
// Efficient bulk indexing
await Post.chunk(1000, async (posts) => {
await Search.indexMany('posts', posts.map(p => p.toSearchableArray()))
})
// Or use the import command
// buddy search:import Post --chunk=1000
Search API
Basic Search Endpoint
// routes/api.ts
router.get('/search', async (request) => {
const { q, type = 'all', page = 1, limit = 20 } = request.query
if (!q || q.length < 2) {
return { results: [], total: 0 }
}
const results = {}
if (type = 'all' || type = 'posts') {
results.posts = await Post.search(q).limit(limit).get()
}
if (type = 'all' || type = 'products') {
results.products = await Product.search(q).limit(limit).get()
}
if (type = 'all' || type = 'users') {
results.users = await User.search(q).limit(limit).get()
}
return results
})
Advanced Search Endpoint
// routes/api.ts
router.post('/search/advanced', async (request) => {
const {
query,
filters = {},
sort = 'relevance',
page = 1,
limit = 20,
} = request.body
let search = Product.search(query)
// Apply filters
if (filters.category) {
search = search.filter(`category = "${filters.category}"`)
}
if (filters.priceMin) {
search = search.filter(`price >= ${filters.priceMin}`)
}
if (filters.priceMax) {
search = search.filter(`price <= ${filters.priceMax}`)
}
if (filters.inStock) {
search = search.filter('in_stock = true')
}
// Apply sorting
switch (sort) {
case 'price_asc':
search = search.sort('price:asc')
break
case 'price_desc':
search = search.sort('price:desc')
break
case 'newest':
search = search.sort('created_at:desc')
break
case 'popular':
search = search.sort('sales:desc')
break
}
// Paginate
const results = await search.paginate(limit, page)
return results
})
Search UI
Search Component
<!-- components/SearchBox.stx -->
<template>
<divclass="
<input
type="search"
v-model="query"
="debouncedSearch"
placeholder="Search..."
class="w-full px-4 py-2 border rounded-lg"
aria-label="Search"
/>
<!-- Results dropdown -->
<div
v-if="showResults && results.length"
class="absolute w-full mt-2 bg-white border rounded-lg shadow-lg"
>
<a
v-for="result in results"
:key="result.id"
:href="result.url"
class="block px-4 py-2 hover:bg-gray-100"
>
<spanv-html="resultquery</span
<spanclass="{{ result.type }}</span
</a
</div
<!-- Loading indicator -->
<divv-if="loadingclass="
<x-spinnersize="
</div
</div
</template
<script>
import { debounce } from '@stacksjs/utils'
export default {
data() {
return {
query: '',
results: [],
loading: false,
showResults: false,
}
},
methods: {
debouncedSearch: debounce(async function() {
if (this.query.length < 2) {
this.results = []
return
}
this.loading = true
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(this.query)}`)
this.results = await response.json()
this.showResults = true
} finally {
this.loading = false
}
}, 300),
highlight(text, query) {
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<mark>$1</mark>')
}
}
}
</script
Faceted Search UI
<!-- components/FacetedSearch.stx -->
<template>
<divclass="
<!-- Filters sidebar -->
<asideclass="
<divv-for="valuesfacetinfacets:key="facetclass="
<h3class="{{ facet }}</h3
<label
v-for="(count, value) in values"
:key="value"
class="flex items-center gap-2"
>
<input
type="checkbox"
:checked="isSelected(facet, value)"
="toggleFilter(facet, value)"
/>
<span{{ value }}</span
<spanclass="({{ count }})</span
</label
</div
</aside
<!-- Results -->
<mainclass="
<divclass="
<x-product-card
v-for="product in results"
:key="product.id"
:product="product"
/>
</div
</main
</div
</template
Search Analytics
Tracking Searches
// Log search queries for analytics
router.get('/search', async (request) => {
const { q } = request.query
// Track search
await SearchLog.create({
query: q,
user_id: request.user?.id,
results_count: results.length,
ip: request.ip,
})
return results
})
Popular Searches
// Get popular searches
const popular = await SearchLog
.select('query')
.selectRaw('COUNT(*) as count')
.where('created_at', '>', dayjs().subtract(7, 'days'))
.groupBy('query')
.orderBy('count', 'desc')
.limit(10)
.get()
Zero-Result Queries
// Find searches with no results (opportunity for content)
const zeroResults = await SearchLog
.select('query')
.selectRaw('COUNT(*) as count')
.where('results_count', 0)
.groupBy('query')
.orderBy('count', 'desc')
.limit(20)
.get()