DEV Community

ahmet gedik
ahmet gedik

Posted on

Building a Video Search Autocomplete System

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');
Enter fullscreen mode Exit fullscreen mode
<?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')");
    }
}
Enter fullscreen mode Exit fullscreen mode

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),
]);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

This autocomplete system powers search at TrendVidStream, returning results in under 50ms thanks to SQLite FTS5 and aggressive caching.

Top comments (0)