SHIN STORYSHIN STORY
홈포스트C#TypeScriptNext.jsNode.js시리즈
</>SHIN STORY

sdf

탐색

  • 홈
  • 모든 포스트
  • 시리즈
  • 검색

카테고리

  • C#
  • TypeScript
  • Next.js
  • Node.js
  • 알고리즘
  • 개발 도구

© 2025 Shin Blog. All rights reserved.

GitHubRSS
목록으로
Next.js#React#Next.js#Markdown

6편. 블로그 프론트엔드와 마크다운 렌더링

SHIN

2025년 3월 25일10분 읽기0
📚

Next.js App Router로 풀스택 블로그 만들기

9편
  1. 11편. 프로젝트 소개와 기술 스택 선정
  2. 22편. 프로젝트 초기 설정과 디렉토리 구조
  3. 33편. Prisma 스키마 설계와 마이그레이션
  4. 44편. NextAuth.js로 관리자 인증 구현하기
  5. 55편. 관리자 대시보드 — 포스트 CRUD 구현
  6. 6편. 블로그 프론트엔드와 마크다운 렌더링현재
  7. 77편. 검색·카테고리·태그·시리즈 기능 구현
  8. 98편. SEO 최적화 — 메타태그·sitemap·RSS
  9. 99편. Vercel 배포와 성능 최적화

6편. 블로그 프론트엔드와 마크다운 렌더링

포스트 목록 페이지

서버 컴포넌트에서 직접 DB를 쿼리합니다. 별도 API 호출 없이 페이지가 렌더링됩니다.

CODE
// 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} />
}

마크다운 렌더링

CODE
// 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>
    </>
  )
}

목차(TOC) 컴포넌트

CODE
'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>
  )
}

읽기 진행률 표시

CODE
'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를 사용하고, 조회수는 별도 클라이언트 컴포넌트에서 처리합니다.

CODE
// 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
}
CODE
// 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 })
}

다음 편에서는 검색, 카테고리, 태그 필터링 기능을 완성합니다.

공유
S

SHIN

.NET 개발자입니다

GitHub

이전 포스트

5편. 관리자 대시보드 — 포스트 CRUD 구현

다음 포스트

7편. 검색·카테고리·태그·시리즈 기능 구현

같은 카테고리 포스트

9편. Vercel 배포와 성능 최적화

2025년 4월 10일· 7분

8편. SEO 최적화 — 메타태그·sitemap·RSS

2025년 4월 5일· 2분

7편. 검색·카테고리·태그·시리즈 기능 구현

2025년 3월 30일· 8분

댓글