Next.js 15 App Router의 Server Component 철학에 충실하면서도, 백엔드 서버와 안정적으로 통신하고,
검색 확장성도 우수한 설계입니다.

소스 : https://github.com/braverokmc79/nextjs-review
⭕ 전체 구조 개요
[✅ 사용자가 검색어 입력]
↓
✅ src/ReviewsSearchBox.tsx
→ 클라이언트 컴포넌트
→ fetch("/api/reviews/search")로 POST 요청 (searchBy, searchTerm 포함)
↓
✅ src/app/api/reviews/search/route.ts
→ API 라우트 (Next.js 서버 컴포넌트에서 동작)
→ getSearchableReviews 호출
↓
✅ src/lib/reviews/reviews.ts
→ 서버 전용(server-only) 모듈
→ Strapi 백엔드 CMS로 fetch 요청 (쿼리 파라미터 구성: qs.stringify 등)
↓
✅ Strapi CMS (혹은 Spring Boot API)
→ 조건 필터링 후 리뷰 데이터 응답
↓
✅ lib/reviews/reviews.ts → 데이터 변환
↓
✅ route.ts → 클라이언트에 JSON 응답
↓
✅ ReviewsSearchBox.tsx → 결과 렌더링
디렉토리 구조 요약
src/
├── app/
│ ├── api/
│ │ └── reviews/
│ │ └── search/route.ts # 검색 API (Next.js 15 API Route)
│ └── reviews/
│ └── page.tsx # 검색 결과 페이지
├── components/
│ └── ReviewsSearchBox.tsx # 클라이언트 자동완성 검색 컴포넌트
├── lib/
│ └── reviews/
│ └── reviews.ts # 백엔드 서버 전용 리뷰 데이터 처리 모듈 server-only 보안적용
└── types/
└── reviewType.ts # 타입 정의
???? 검색 흐름 요약
사용자가 ReviewsSearchBox에 키워드 입력
POST /api/reviews/search API 호출 (자동완성 요청)
route.ts → getSearchableReviews() 실행 → Strapi CMS에서 리뷰 검색
결과 리뷰 데이터 중 필요한 필드(title, subtitle, slug)를 추출해 자동완성 목록 표시
항목 클릭 또는 Enter 시 → /reviews?searchBy=xxx&search=yyy로 이동
✅ 핵심 포인트 요약

1)src/lib/reviews/reviews.ts
// src/lib/reviews/reviews.ts
import 'server-only';
import { marked } from "marked";
import { notFound } from "next/navigation";
import qs from "qs";
import { GetReviewData, Review, Reviews, ReviewSearchByEnum } from '@/types/reviewType';
const CMS_URL = process.env.BACKEND_URL || "";
export const CACHE_TAG_REVIEWS = "reviews";
/**
* 백엔드에서 받아온 리뷰 데이터 객체를 우리가 사용할 형태로 변환
*/
function toReview(item: GetReviewData) {
const { id } = item;
const { slug, title, subtitle, publishedAt } = item;
const image = item === undefined
? undefined
: new URL(item?.image[0].url, CMS_URL).href;
//new URL('/uploads/1.jpg', 'https://example.com').href // "https://example.com/uploads/1.jpg"
const date = publishedAt.slice(0, "yyyy-mm-dd".length);
return { id, slug, title, subtitle, date, image };
}
/**
* 리뷰 데이터를 백엔드에서 요청해 가져오는 함수
* - 필드, 정렬, 필터, 페이지네이션 등 파라미터를 전달 가능
*/
export async function fetchReviews(parameters: object) {
const url = `${CMS_URL}/api/reviews?` + qs.stringify(parameters, { encodeValuesOnly: true });
const response = await fetch(url, {
next: {
tags: [CACHE_TAG_REVIEWS],
}
});
if (!response.ok) {
throw new Error(`Backend returned ${response.status} for ${url}`);
}
return await response.json();
}
/**
* 슬러그(slug)를 기준으로 특정 리뷰 상세 데이터 가져오기
* - 본문은 markdown → HTML로 변환됨
* - 리뷰가 없으면 notFound() 호출
*/
export async function getReview(slug: string): Promise<Review | null> {
try {
const { data } = await fetchReviews({
filters: { slug: { $eq: slug } },
fields: ["slug", "title", "subtitle", "publishedAt", "body"],
populate: { image: { fields: ["url"] } },
pagination: { pageSize: 1, withCount: false },
});
if (data.length === 0) {
return null;
}
const item = data[0];
return {
...toReview(item),
body: await marked(item.body),
}
} catch (error) {
console.error("Error fetching review:", error);
notFound();
}
}
/**
* 리뷰 리스트 데이터를 불러오는 함수
* - 페이지네이션, 검색 기준, 검색어 등 포함 가능
* - UI에서 목록 출력 시 사용
*/
export async function getReviews(
pageSize: number = 6,
page: number = 1,
searchBy?: ReviewSearchByEnum,
searchTerm?: string
): Promise<Reviews> {
let filters = undefined;
console.log("⭕ getReviews :" ,pageSize, page,searchBy,searchTerm);
if (searchTerm && searchBy) {
if (searchBy === 'all') {
filters = {
$or: [
{ title: { $containsi: searchTerm } },
{ subtitle: { $containsi: searchTerm } },
{ slug: { $containsi: searchTerm } },
],
}
} else {
filters = {
[searchBy]: {
$containsi: searchTerm,
},
}
}
}
const { data, meta } = await fetchReviews({
fields: ['slug', 'title', 'subtitle', 'publishedAt'],
populate: { image: { fields: ['url'] } },
sort: ['publishedAt:desc'],
pagination: { pageSize, page },
filters: filters&&filters
})
return {
total: meta.pagination.total,
pageCount: meta.pagination.pageCount,
reviews: data.map((item: GetReviewData) => toReview(item)),
}
}
/**
* 자동완성 검색 기능에 사용되는 리뷰 제목/부제목/슬러그 간략 정보만 불러오기
* - 클라이언트 자동완성 입력 필드에서 호출됨
*/
export async function getSearchableReviews(
pageSize: number = 6,
page: number = 1,
searchBy?: ReviewSearchByEnum,
searchTerm?: string
) {
let filters = undefined;
if (searchTerm && searchBy) {
if (searchBy === 'all') {
filters = {
$or: [
{ title: { $containsi: searchTerm } },
{ subtitle: { $containsi: searchTerm } },
{ slug: { $containsi: searchTerm } },
],
}
} else {
filters = {
[searchBy]: {
$containsi: searchTerm,
},
}
}
}
const { data, meta } = await fetchReviews({
fields: ['slug', 'title', 'subtitle'],
sort: [searchBy === 'all' ? 'title:asc' : `${searchBy}:asc`],
pagination: { pageSize, page },
filters,
})
return {
total: meta.pagination.total,
pageCount: meta.pagination.pageCount,
reviews: data.map((item: GetReviewData) => {
return {
slug: item.slug,
title: item.title,
subtitle: item.subtitle,
}
}),
}
}
/**
* 전체 리뷰들의 슬러그 목록을 가져오는 함수
* - 정적 페이지 생성 등에 활용됨
*/
export async function getSlugs() {
const { data } = await fetchReviews({
fields: ["slug"],
sort: ["publishedAt:desc"],
pagination: { pageSize: 1000 },
});
return data.map((item: GetReviewData) => item.slug);
}
//slug 가 이미 등록되었는지 확인
export async function getReviewSlugsExists(slug: string) {
const { data } = await fetchReviews({
filters: { slug: { $eq: slug } },
pagination: { pageSize: 1 }, // 단일 항목만 가져오도록 제한
fields: ["slug"],
});
return data.length > 0 ? true : false
}
/**
* 최신 리뷰 하나를 가져오는 함수
* - 홈 화면이나 추천 콘텐츠 등에 사용
*/
export async function getFeaturedReview(): Promise<Review> {
const { reviews } = await getReviews();
return reviews[0];
}
⭕서버 전용 모듈로의 분리
src/lib/reviews/reviews.ts는 import 'server-only' 선언을 통해 Server Component에서만 사용 가능하게 제한
보안이 필요한 백엔드 서버 요청, markdown 파싱 등을 서버 환경에서만 처리함으로써 클라이언트 노출 방지
✅ fetchReviews() 세부 설명
Strapi CMS의 /api/reviews 엔드포인트에 요청
qs.stringify()로 파라미터를 URL에 직렬화
next: { tags: ["reviews"] }를 통해 Next.js의 캐시 태깅 적용
const url = `${CMS_URL}/api/reviews?` + qs.stringify(parameters, { encodeValuesOnly: true });
2)/src/app/reviews/page.tsx
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/16/solid";
import Link from "next/link";
import React from "react";
interface PaginationBarProps {
href: string;
page: string | number;
defaultPage?: number;
pageSize: string | number;
defaultPageSize?: number;
pageCount: number;
searchBy?: string;
search?: string;
}
const PaginationBar: React.FC<PaginationBarProps> = ({href, page, defaultPage=1, pageSize,
defaultPageSize=6 , pageCount , searchBy, search}) => {
const safePage = parseNumberCheck(page, defaultPage) ;
const safePageSize = parseNumberCheck(pageSize, defaultPageSize) ;
const prevHref = buildQueryString(href, {
page: safePage - 1,
pageSize: safePageSize,
searchBy,
search,
});
const nextHref = buildQueryString(href, {
page: safePage + 1,
pageSize: safePageSize,
searchBy,
search,
});
return (
<div className="flex gap-2 pb-3 relative">
<PaginationLink href={prevHref} enabled={safePage > 1}>
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Previous Page</span>
</PaginationLink>
<span>
페이지 {safePage} / {pageCount}
</span>
<PaginationLink href={nextHref} enabled={safePage < pageCount}>
<ChevronRightIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Next Page</span>
</PaginationLink>
</div>
);
};
export default PaginationBar;
function PaginationLink({children, enabled,href} :{children: React.ReactNode,enabled: boolean, href: string}) {
if (!enabled) {
return (
<span className="h-7 w-7 items-center justify-center flex
border rounded text-slate-500 text-sm hover:bg-orange-100 hover:text-slate-700 ">
{children}
</span>
);
}
return(
<Link href={href}
className="
h-7 w-7 items-center justify-center flex
border rounded text-slate-500 text-sm hover:bg-orange-100 hover:text-slate-700 " >
{children}
</Link>
);
}
export function parseNumberCheck(paramValue: string | number, defaultValue: number): number {
if (paramValue) {
let page=1;
if(typeof paramValue === 'string') {
page = parseInt(paramValue, 10);
}else{
page = paramValue as number;
}
if (isFinite(page) && page > 0) {
return page;
}
}
return defaultValue;
}
const buildQueryString = (base: string, params: Record<string, any>) => {
const query = new URLSearchParams();
for (const key in params) {
const value = params[key];
if (value !== undefined && value !== null && value !== "") {
query.append(key, String(value));
}
}
return `${base}?${query.toString()}`;
};
3)/src/components/ReviewsSearchBox.tsx
'use client'
// /src/components/ReviewsSearchBox.tsx
import React, { useEffect, useState, useRef } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { Review, ReviewSearchByEnum } from '@/types/reviewType'
interface ReviewsSearchBoxProps {
searchBy?: string;
searchTerm?: string;
}
const ReviewsSearchBox: React.FC<ReviewsSearchBoxProps> = ({ searchBy = 'title', searchTerm = '' }) => {
const router = useRouter()
const [by, setBy] = useState<ReviewSearchByEnum>(searchBy as ReviewSearchByEnum);
const [term, setTerm] = useState(searchTerm);
const [suggestions, setSuggestions] = useState<string[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1)
const inputRef = useRef<HTMLInputElement>(null)
const selectedRef = useRef<HTMLLIElement>(null)
//키보드 탐색 시 리스트가 길어지면 현재 선택된 항목이 보이지 않을 경우
useEffect(() => {
if (selectedRef.current) {
selectedRef.current.scrollIntoView({ block: 'nearest' })
}
}, [highlightedIndex])
useEffect(() => {
const delayDebounce = setTimeout(async () => {
if (by && term.length > 1) {
const response = await fetch('/api/reviews/search', {
method: 'POST',
body: JSON.stringify({ searchBy: by, searchTerm: term }),
});
const data = await response.json();
if(!data.success){
return null;
}
setSuggestions(data.reviews.map((review: Review) => by === 'all' ? review.title : review[by]))
setShowSuggestions(true)
setHighlightedIndex(-1)
} else {
setSuggestions([])
setShowSuggestions(false)
}
}, 300)
return () => clearTimeout(delayDebounce)
}, [term, by]);
const handleSearch = async (value?: string) => {
const keyword = value ?? term
const params = new URLSearchParams()
params.set('searchBy', by)
params.set('search', keyword)
router.push(`/reviews?${params.toString()}`)
setTerm(keyword)
router.refresh()
setShowSuggestions(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown' && suggestions.length > 0) {
e.preventDefault()
setHighlightedIndex((prev) => (prev + 1) % suggestions.length)
}
if (e.key === 'ArrowUp' && suggestions.length > 0) {
e.preventDefault()
setHighlightedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length)
}
if (e.key === 'Enter') {
e.preventDefault()
if (highlightedIndex >= 0 && suggestions[highlightedIndex]) {
handleSearch(suggestions[highlightedIndex])
} else {
handleSearch()
}
setShowSuggestions(false) // 자동완성 닫기
setHighlightedIndex(-1)
}
if (e.key === 'Escape') {
setShowSuggestions(false)
setHighlightedIndex(-1)
}
}
return (
<div className="flex items-center gap-2 w-full max-w-xl mx-auto p-4">
<Select value={by} onValueChange={(val) => setBy(val as ReviewSearchByEnum)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="검색 기준" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ReviewSearchByEnum.all}>전체</SelectItem>
<SelectItem value={ReviewSearchByEnum.slug}>Slug</SelectItem>
<SelectItem value={ReviewSearchByEnum.title}>Title</SelectItem>
<SelectItem value={ReviewSearchByEnum.subtitle}>Subtitle</SelectItem>
</SelectContent>
</Select>
<div className="relative w-full">
<Input
ref={inputRef}
type="search"
placeholder={`${by === 'all' ? '전체' : by}로 검색`}
value={term}
onChange={(e) => setTerm(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onKeyDown={handleKeyDown}
className="w-full"
/>
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute z-10 mt-1 w-full bg-white border
border-gray-300 rounded-md shadow-md max-h-60 overflow-auto">
{suggestions.map((s, i) => (
<li
ref={i === highlightedIndex ? selectedRef : null}
key={i}
className={`px-4 py-2 cursor-pointer text-sm ${
i === highlightedIndex ? 'bg-gray-200 font-semibold ' : 'hover:bg-gray-100'
}`}
onMouseEnter={() => setHighlightedIndex(i)}
onMouseDown={() => handleSearch(s)}
>
{s}
</li>
))}
</ul>
)}
</div>
<Button variant="default" size="icon" onClick={() => handleSearch()}>
<Search className="h-4 w-4" />
</Button>
</div>
)
}
export default ReviewsSearchBox;
✅ ReviewsSearchBox 클라이언트 컴포넌트 주요 동작
항목설명
검색 기준Select UI로 title, slug, subtitle, all 중 선택 가능
검색어 입력Input 필드 입력 시 debounce로 300ms 후 검색 API 호출
자동완성 리스트입력 키워드 기준으로 백엔드 응답 받아 자동완성 표시
키보드 탐색↑ ↓ 키로 자동완성 항목 탐색, Enter로 선택 가능
검색 실행버튼 클릭 또는 키보드 입력으로 /reviews 페이지 이동
✅ 기술 요소
useRouter → 검색 결과 페이지로 이동 및 새로고침 처리
fetch('/api/reviews/search') → POST 방식으로 자동완성 요청
useEffect → 입력 감지, debounce 적용, 백엔드 요청
useState → 입력어, 선택 기준, 자동완성 상태 등 저장
scrollIntoView() → 선택된 자동완성 항목이 항상 보이도록 조정
4)src/app/api/reviews/search
// src/app/api/reviews/search
import { NextRequest, NextResponse } from "next/server";
import { getSearchableReviews } from "@/lib/reviews/reviews";
export async function POST(request: NextRequest) {
const body = await request.json();
const { searchBy, searchTerm } = body;
try {
const { reviews } = await getSearchableReviews(6, 1, searchBy, searchTerm);
return NextResponse.json({
success: true,
reviews,
});
} catch (error:unknown) {
console.error('검색 중 오류:', error);
return NextResponse.json({
success: false,
message: '검색 중 오류 발생',
}, { status: 500 });
}
}
✅ route.ts API 핸들러의 흐름
클라이언트에서 보낸 JSON body를 파싱
getSearchableReviews() 호출
JSON 형식으로 클라이언트에 검색 결과 응답















댓글 ( 0)
댓글 남기기