SHIN
Next.js App Router로 풀스택 블로그 만들기
9편Prisma의 OR + contains로 제목, 내용, 태그, 카테고리를 동시에 검색합니다.
const where = {
status: 'PUBLISHED',
OR: [
{ title: { contains: query } },
{ excerpt: { contains: query } },
{ content: { contains: query } },
{ tags: { some: { tag: { name: { contains: query } } } } },
{ category: { name: { contains: query } } },
],
}
const [posts, total] = await Promise.all([
prisma.post.findMany({ where, take, skip, orderBy: { publishedAt: 'desc' } }),
prisma.post.count({ where }),
])SQLite FTS5를 사용하면 훨씬 빠른 전문 검색이 가능하지만, 이 시리즈에서는 Prisma 레벨로 단순하게 구현합니다.
function highlight(text: string, query: string) {
if (!query.trim()) return <>{text}</>
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const parts = text.split(new RegExp(`(${escaped})`, 'gi'))
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase()
? <mark key={i} className="bg-indigo-500/30 text-indigo-200 rounded px-0.5">{part}</mark>
: part
)}
</>
)
}필터 상태를 URL에 담으면 새로고침, 공유, 북마크 모두 동작합니다.
// src/components/blog/PostsFilterSidebar.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
export function PostsFilterSidebar({ categories, tags }) {
const router = useRouter()
const sp = useSearchParams()
const setFilter = (key: string, value: string | null) => {
const params = new URLSearchParams(sp.toString())
if (value) params.set(key, value)
else params.delete(key)
params.delete('page') // 필터 변경 시 1페이지로 리셋
router.push(`/posts?${params.toString()}`)
}
return (
<aside>
<h3>카테고리</h3>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => setFilter('category', sp.get('category') === cat.slug ? null : cat.slug)}
className={sp.get('category') === cat.slug ? 'active' : ''}
>
{cat.name}
</button>
))}
</aside>
)
}function Pagination({ page, totalPages, buildUrl }) {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
.reduce<(number | '...')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('...')
acc.push(p)
return acc
}, [])
return (
<div className="flex gap-1 justify-center">
{page > 1 && <Link href={buildUrl(page - 1)}>이전</Link>}
{pages.map((p, i) =>
p === '...'
? <span key={`d${i}`}>…</span>
: <Link key={p} href={buildUrl(p)} className={p === page ? 'active' : ''}>{p}</Link>
)}
{page < totalPages && <Link href={buildUrl(page + 1)}>다음</Link>}
</div>
)
}포스트 상세에서 같은 시리즈의 목록을 보여주고 현재 편을 강조합니다.
{post.series.map(({ series }) => (
<div key={series.id} className="series-box">
<h3>{series.name}</h3>
<ol>
{series.posts.map((sp) => {
const isCurrent = sp.postId === post.id
return (
<li key={sp.postId} className={isCurrent ? 'current' : ''}>
{isCurrent ? (
<span>{sp.post.title} <Badge>현재</Badge></span>
) : (
<Link href={`/posts/${sp.post.slug}`}>{sp.post.title}</Link>
)}
</li>
)
})}
</ol>
</div>
))}다음 편에서는 OpenGraph, JSON-LD, sitemap, RSS 등 SEO 최적화를 다룹니다.