Search autocomplete improves user experience significantly. Here's how I built a fast, lightweight autocomplete system for TrendVidStream using PHP, SQLite FTS5, and vanilla JavaScript.
Backend: SQLite FTS5
SQLite's FTS5 extension provides full-text search with prefix matching, which is perfect for autocomplete.
-- Create FTS5 virtual table
CREATE VIRTUAL TABLE IF NOT EXISTS videos_fts USING fts5(
title,
channel_title,
content='videos',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
-- Rebuild index (run after fetching new videos)
INSERT INTO videos_fts(videos_fts) VALUES('rebuild');
<?php
class SearchService
{
private PDO $db;
private Cache $cache;
public function __construct(PDO $db, Cache $cache)
{
$this->db = $db;
$this->cache = $cache;
}
public function autocomplete(string $query, int $limit = 8): array
{
$query = trim($query);
if (strlen($query) < 2) {
return [];
}
// Check cache
$cacheKey = 'autocomplete:' . md5($query);
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return $cached;
}
// FTS5 prefix search
$ftsQuery = $this->buildFtsQuery($query);
$stmt = $this->db->prepare('
SELECT v.id, v.title, v.channel_title, v.thumbnail,
rank
FROM videos_fts
JOIN videos v ON v.rowid = videos_fts.rowid
WHERE videos_fts MATCH ?
ORDER BY rank
LIMIT ?
');
$stmt->execute([$ftsQuery, $limit]);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Cache for 10 minutes
$this->cache->set($cacheKey, $results, 600);
return $results;
}
private function buildFtsQuery(string $query): string
{
// Tokenize and add prefix matching
$words = preg_split('/\s+/', $query);
$terms = array_map(
fn(string $word) => '"' . SQLite3::escapeString($word) . '"*',
$words
);
return implode(' ', $terms);
}
public function rebuildIndex(): void
{
$this->db->exec("INSERT INTO videos_fts(videos_fts) VALUES('rebuild')");
}
}
API Endpoint
<?php
// GET /api/search/autocomplete?q=trending+music
header('Content-Type: application/json');
header('Cache-Control: public, max-age=600');
$query = $_GET['q'] ?? '';
$results = $searchService->autocomplete($query);
echo json_encode([
'query' => $query,
'results' => array_map(fn($r) => [
'id' => $r['id'],
'title' => $r['title'],
'channel' => $r['channel_title'],
'thumb' => $r['thumbnail'],
], $results),
]);
Frontend: Vanilla JavaScript
class SearchAutocomplete {
constructor(inputSelector, resultsSelector) {
this.input = document.querySelector(inputSelector);
this.results = document.querySelector(resultsSelector);
this.timer = null;
this.currentQuery = '';
this.cache = new Map();
this.input.addEventListener('input', (e) => this.onInput(e));
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
document.addEventListener('click', (e) => {
if (!this.input.contains(e.target) && !this.results.contains(e.target)) {
this.hide();
}
});
}
onInput(e) {
const query = e.target.value.trim();
clearTimeout(this.timer);
if (query.length < 2) {
this.hide();
return;
}
// Debounce 300ms
this.timer = setTimeout(() => this.search(query), 300);
}
async search(query) {
if (query === this.currentQuery) return;
this.currentQuery = query;
// Check client-side cache
if (this.cache.has(query)) {
this.render(this.cache.get(query));
return;
}
try {
const resp = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(query)}`);
const data = await resp.json();
// Cache on client
this.cache.set(query, data.results);
// Only render if query hasn't changed
if (query === this.currentQuery) {
this.render(data.results);
}
} catch (err) {
console.error('Autocomplete error:', err);
}
}
render(results) {
if (results.length === 0) {
this.hide();
return;
}
this.results.innerHTML = results.map(r => `
<a href="/watch/${r.id}" class="ac-item">
<img src="${r.thumb}" alt="" width="120" height="68" loading="lazy">
<div class="ac-text">
<div class="ac-title">${this.escapeHtml(r.title)}</div>
<div class="ac-channel">${this.escapeHtml(r.channel)}</div>
</div>
</a>
`).join('');
this.results.classList.add('visible');
}
hide() {
this.results.classList.remove('visible');
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
onKeydown(e) {
if (e.key === 'Escape') this.hide();
}
}
// Initialize
new SearchAutocomplete('#search-input', '#search-results');
CSS
.ac-results {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
max-height: 400px;
overflow-y: auto;
z-index: 100;
}
.ac-results.visible { display: block; }
.ac-item {
display: flex;
gap: 12px;
padding: 8px 12px;
text-decoration: none;
color: #e0e0e0;
transition: background 0.15s;
}
.ac-item:hover { background: #2a2a4a; }
.ac-title { font-weight: 500; font-size: 0.9rem; }
.ac-channel { font-size: 0.8rem; color: #888; }
This autocomplete system powers search at TrendVidStream, returning results in under 50ms thanks to SQLite FTS5 and aggressive caching.
Top comments (0)