Astro กับ Algolia: Full-text Search
#astro13 เม.ย. 2569
Astro กับ Algolia: Full-text Search
Algolia เป็น search-as-a-service platform ที่ให้ full-text search ที่รวดเร็วและแม่นยำ ด้วย typo tolerance, faceted search, และ instant results ทำให้ Algolia เป็นตัวเลือกยอดนิยมสำหรับ websites ที่ต้องการ search functionality ระดับสูง
ทำไมต้องใช้ Algolia?
- Speed: ผลลัพธ์ใน milliseconds
- Typo Tolerance: ค้นหาได้แม้พิมพ์ผิด
- Faceted Search: กรองผลลัพธ์ตาม attributes
- Analytics: วิเคราะห์พฤติกรรมการค้นหา
- InstantSearch: UI components สำเร็จรูป
การติดตั้ง
npm install algoliasearch @algolia/client-search
npm install react-instantsearch # ถ้าใช้ React
ตั้งค่า environment variables:
# .env
ALGOLIA_APP_ID=your-app-id
ALGOLIA_SEARCH_KEY=your-search-only-api-key
ALGOLIA_ADMIN_KEY=your-admin-api-key
ALGOLIA_INDEX_NAME=your-index-name
สร้าง Algolia Client
// src/lib/algolia.ts
import algoliasearch from 'algoliasearch';
export const searchClient = algoliasearch(
import.meta.env.ALGOLIA_APP_ID,
import.meta.env.ALGOLIA_SEARCH_KEY
);
export const adminClient = algoliasearch(
import.meta.env.ALGOLIA_APP_ID,
import.meta.env.ALGOLIA_ADMIN_KEY
);
export const searchIndex = searchClient.initIndex(
import.meta.env.ALGOLIA_INDEX_NAME
);
export const adminIndex = adminClient.initIndex(
import.meta.env.ALGOLIA_INDEX_NAME
);
// TypeScript type สำหรับ search record
export interface SearchRecord {
objectID: string;
title: string;
excerpt: string;
content: string;
slug: string;
tags: string[];
publishedAt: number; // Unix timestamp
author: string;
}
Indexing Content
สร้าง script สำหรับ index content:
// scripts/indexContent.ts
import { adminIndex } from '../src/lib/algolia';
import type { SearchRecord } from '../src/lib/algolia';
async function indexAllContent() {
// ดึงข้อมูลจาก CMS หรือ markdown files
const posts = await getAllPosts();
const records: SearchRecord[] = posts.map((post) => ({
objectID: post.id,
title: post.title,
excerpt: post.excerpt,
content: stripHtml(post.content).substring(0, 5000),
slug: post.slug,
tags: post.tags,
publishedAt: new Date(post.publishedAt).getTime() / 1000,
author: post.author.name,
}));
// Configure index settings
await adminIndex.setSettings({
searchableAttributes: [
'title',
'excerpt',
'content',
'tags',
'author',
],
attributesForFaceting: ['tags', 'author'],
customRanking: ['desc(publishedAt)'],
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
});
// Batch index
const { objectIDs } = await adminIndex.saveObjects(records);
console.log(`Indexed ${objectIDs.length} records`);
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
async function getAllPosts() {
// implement fetching posts
return [];
}
indexAllContent().catch(console.error);
Search API Route
// src/pages/api/search.ts
import type { APIRoute } from 'astro';
import { searchIndex } from '../../lib/algolia';
export const GET: APIRoute = async ({ url }) => {
const query = url.searchParams.get('q') ?? '';
const page = parseInt(url.searchParams.get('page') ?? '0');
const tags = url.searchParams.getAll('tag');
if (!query.trim()) {
return new Response(JSON.stringify({ hits: [], nbHits: 0 }), {
headers: { 'Content-Type': 'application/json' },
});
}
const results = await searchIndex.search(query, {
page,
hitsPerPage: 10,
attributesToHighlight: ['title', 'excerpt'],
attributesToSnippet: ['content:30'],
facetFilters: tags.length ? [tags.map((t) => `tags:${t}`)] : undefined,
facets: ['tags'],
});
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' },
});
};
Search UI Component (React)
// src/components/SearchBox.tsx
import { useState, useCallback } from 'react';
import { useDebounce } from '../hooks/useDebounce';
interface SearchHit {
objectID: string;
title: string;
excerpt: string;
slug: string;
_highlightResult?: {
title?: { value: string };
excerpt?: { value: string };
};
}
export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchHit[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const search = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
setResults(data.hits);
} finally {
setLoading(false);
}
}, []);
// Search when debounced query changes
useState(() => {
search(debouncedQuery);
});
return (
<div className="search-container">
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
placeholder="ค้นหาบทความ..."
className="search-input"
/>
{isOpen && results.length > 0 && (
<div className="search-results">
{loading && <p>กำลังค้นหา...</p>}
{results.map((hit) => (
<a key={hit.objectID} href={`/blog/${hit.slug}`} className="search-hit">
<h3
dangerouslySetInnerHTML={{
__html: hit._highlightResult?.title?.value ?? hit.title,
}}
/>
<p
dangerouslySetInnerHTML={{
__html: hit._highlightResult?.excerpt?.value ?? hit.excerpt,
}}
/>
</a>
))}
</div>
)}
</div>
);
}
Webhook สำหรับ Auto-indexing
// src/pages/api/algolia-sync.ts
import type { APIRoute } from 'astro';
import { adminIndex } from '../../lib/algolia';
export const POST: APIRoute = async ({ request }) => {
const secret = request.headers.get('x-webhook-secret');
if (secret !== import.meta.env.WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const { action, post } = await request.json();
if (action === 'publish') {
await adminIndex.saveObject({
objectID: post.id,
title: post.title,
excerpt: post.excerpt,
content: post.content,
slug: post.slug,
tags: post.tags,
});
} else if (action === 'unpublish' || action === 'delete') {
await adminIndex.deleteObject(post.id);
}
return new Response(JSON.stringify({ success: true }));
};
สรุป
Algolia กับ Astro ทำให้การเพิ่ม search functionality เป็นเรื่องง่าย ด้วย instant search, typo tolerance, และ faceted filtering ทำให้ user experience ในการค้นหาดีขึ้นอย่างมาก การ sync content ผ่าน webhooks ทำให้ search index อัปเดตอัตโนมัติเมื่อมีการเปลี่ยนแปลง content