SHIN
Next.js App Router로 풀스택 블로그 만들기
9편서버 컴포넌트에서 직접 DB를 쿼리합니다. 별도 API 호출 없이 페이지가 렌더링됩니다.
// src/app/(blog)/posts/page.tsx
import { prisma } from '@/lib/prisma'
interface Props {
searchParams: Promise<{ category?: string; tag?: string; page?: string }>
}
export default async function PostsPage({ searchParams }: Props) {
const sp = await searchParams
const page = parseInt(sp.page || '1')
const take = 10
const where = {
status: 'PUBLISHED',
...(sp.category && { category: { slug: sp.category } }),
...(sp.tag && { tags: { some: { tag: { slug: sp.tag } } } }),
}
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
include: { author: true, category: true, tags: { include: { tag: true } }, _count: { select: { comments: true } } },
orderBy: [{ pinned: 'desc' }, { publishedAt: 'desc' }],
take,
skip: (page - 1) * take,
}),
prisma.post.count({ where }),
])
return <PostList posts={posts} total={total} page={page} />
}// src/components/blog/PostContent.tsx
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
interface Props {
content: string
codeTheme?: string
}
export function PostContent({ content, codeTheme = 'github-dark' }: Props) {
return (
<>
<link
rel="stylesheet"
href={`https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/${codeTheme}.min.css`}
/>
<div className="prose prose-invert prose-indigo max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeSlug]}
>
{content}
</ReactMarkdown>
</div>
</>
)
}'use client'
import { useEffect, useState } from 'react'
export function TableOfContents({ content }: { content: string }) {
const [activeId, setActiveId] = useState('')
useEffect(() => {
const headings = document.querySelectorAll('.prose h1, .prose h2, .prose h3')
const observer = new IntersectionObserver(
entries => entries.forEach(e => { if (e.isIntersecting) setActiveId(e.target.id) }),
{ rootMargin: '-80px 0px -70% 0px' }
)
headings.forEach(h => observer.observe(h))
return () => observer.disconnect()
}, [content])
// 마크다운에서 헤딩 파싱
const items = content
.split('\n')
.filter(line => /^#{1,3} /.test(line))
.map(line => {
const level = line.match(/^(#+)/)?.[1].length ?? 1
const text = line.replace(/^#+\s/, '')
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
return { level, text, id }
})
return (
<nav className="sticky top-24">
<ul className="border-l border-gray-800 space-y-1">
{items.map(item => (
<li key={item.id}>
<a
href={`#${item.id}`}
className={`block text-sm py-1 pl-${item.level * 3} transition-colors
${activeId === item.id ? 'text-indigo-400' : 'text-gray-500 hover:text-gray-300'}`}
>
{item.text}
</a>
</li>
))}
</ul>
</nav>
)
}'use client'
import { useEffect, useState } from 'react'
export function ReadingProgress() {
const [progress, setProgress] = useState(0)
useEffect(() => {
const update = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement
setProgress((scrollTop / (scrollHeight - clientHeight)) * 100)
}
window.addEventListener('scroll', update, { passive: true })
return () => window.removeEventListener('scroll', update)
}, [])
return (
<div className="fixed top-0 left-0 right-0 z-50 h-0.5 bg-gray-800">
<div
className="h-full bg-indigo-500 transition-[width] duration-150"
style={{ width: `${progress}%` }}
/>
</div>
)
}서버 컴포넌트에서는 cache를 사용하고, 조회수는 별도 클라이언트 컴포넌트에서 처리합니다.
// src/components/blog/ViewTracker.tsx
'use client'
import { useEffect } from 'react'
export function ViewTracker({ postId }: { postId: string }) {
useEffect(() => {
fetch(`/api/posts/${postId}/view`, { method: 'POST' })
}, [postId])
return null
}// src/app/api/posts/[id]/view/route.ts
export async function POST(req: Request, { params }) {
await prisma.post.update({
where: { id: params.id },
data: { viewCount: { increment: 1 } },
})
return Response.json({ ok: true })
}다음 편에서는 검색, 카테고리, 태그 필터링 기능을 완성합니다.