Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지

6 min read
Next.jsReactMDX

메인 페이지: 카드 그리드

블로그의 첫인상은 메인 페이지에서 결정된다. Velog처럼 깔끔한 카드 그리드로 구성하기로 했다.

PostCard 컴포넌트

각 카드에 표시할 정보는 다음과 같다:

  • 썸네일 이미지 (없으면 기본 플레이스홀더)
  • 제목
  • 설명 (2줄 제한)
  • 태그
  • 작성일 + 읽기 시간
src/components/post/PostCard.tsx
import Link from 'next/link'
import dayjs from 'dayjs'
import Tag from '@/components/common/Tag'
import { SITE_CONFIG } from '@/lib/constants'
import type { PostMeta } from '@/types/post'
 
export default function PostCard({ post }: { post: PostMeta }) {
  const thumbnail = post.thumbnail ?? SITE_CONFIG.defaultThumbnail
 
  return (
    <Link href={`/posts/${post.slug}`} className="group block">
      <article className="h-full overflow-hidden rounded-lg
        border border-border bg-bg-card
        transition-all duration-200
        hover:-translate-y-1 hover:shadow-lg">
 
        <div className="aspect-video overflow-hidden bg-bg-secondary">
          <img
            src={thumbnail}
            alt={post.title}
            className="h-full w-full object-cover
              transition-transform duration-200
              group-hover:scale-105"
            loading="lazy"
          />
        </div>
 
        <div className="p-5">
          <h2 className="mb-2 text-lg font-bold leading-snug
            text-text group-hover:text-accent">
            {post.title}
          </h2>
          <p className="mb-4 line-clamp-2 text-sm text-text-secondary">
            {post.description}
          </p>
          {post.tags.length > 0 && (
            <div className="mb-3 flex flex-wrap gap-1.5">
              {post.tags.map((tag) => (
                <Tag key={tag} name={tag} />
              ))}
            </div>
          )}
          <div className="text-xs text-text-tertiary">
            <time dateTime={post.date}>
              {dayjs(post.date).format('YYYY년 M월 D일')}
            </time>
            <span> · </span>
            <span>{post.readingTime}</span>
          </div>
        </div>
      </article>
    </Link>
  )
}

호버 시 두 가지 효과를 넣었다:

  1. hover:-translate-y-1 — 카드가 살짝 위로 떠오르는 효과
  2. group-hover:scale-105 — 썸네일이 부드럽게 확대

line-clamp-2는 설명이 길어도 2줄까지만 보여주고 말줄임표 처리를 해준다.

반응형 그리드

src/components/post/PostCardGrid.tsx
export default function PostCardGrid({ posts }: { posts: readonly PostMeta[] }) {
  if (posts.length === 0) {
    return (
      <div className="py-20 text-center text-text-secondary">
        <p className="text-lg">아직 작성된 글이 없습니다.</p>
      </div>
    )
  }
 
  return (
    <div className="grid grid-cols-1 gap-5
      sm:grid-cols-2 lg:grid-cols-3">
      {posts.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </div>
  )
}

Tailwind의 반응형 prefix로 간단하게 처리했다:

  • 모바일 (기본): 1열
  • sm (640px~): 2열
  • lg (1024px~): 3열

홈 페이지

src/app/page.tsx
import PostCardGrid from '@/components/post/PostCardGrid'
import { getAllPostMetas } from '@/lib/posts'
 
export default function HomePage() {
  const posts = getAllPostMetas()
 
  return (
    <section className="mx-auto max-w-5xl px-4 py-10">
      <PostCardGrid posts={posts} />
    </section>
  )
}

Server Component이기 때문에 getAllPostMetas()를 바로 호출할 수 있다. API 엔드포인트도 필요 없고, useEffect도 필요 없다. 이게 App Router의 큰 장점이다.

포스트 상세 페이지

정적 경로 생성

generateStaticParams로 빌드 타임에 모든 포스트의 경로를 미리 생성한다:

src/app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const slugs = getAllSlugs()
  return slugs.map((slug) => ({ slug }))
}

이렇게 하면 pnpm build 시 각 포스트가 개별 HTML 파일로 생성된다.

SEO 메타데이터

generateMetadata로 frontmatter 기반의 동적 메타데이터를 설정한다:

export async function generateMetadata({
  params,
}: PostPageProps): Promise<Metadata> {
  const { slug } = await params
  const post = getPostBySlug(slug)
 
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.date,
      tags: [...post.tags],
    },
  }
}

이 설정으로 SNS 공유 시 제목, 설명, 작성일이 자동으로 표시된다.

MDX 렌더링

상세 페이지의 핵심은 MDX를 React 컴포넌트로 컴파일하는 것이다:

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params
  const post = getPostBySlug(slug)
  const content = await compileMdx(post.content, mdxComponents)
 
  return (
    <article className="py-10">
      <header className="mx-auto mb-10 max-w-3xl px-4">
        <h1 className="mb-4 text-3xl font-extrabold
          leading-tight tracking-tight md:text-4xl">
          {post.title}
        </h1>
        <div className="text-sm text-text-secondary">
          <time>{dayjs(post.date).format('YYYY년 M월 D일')}</time>
          <span> · {post.readingTime}</span>
        </div>
        <div className="mt-4 flex flex-wrap gap-2">
          {post.tags.map((tag) => <Tag key={tag} name={tag} />)}
        </div>
      </header>
      <PostContent>{content}</PostContent>
    </article>
  )
}

compileMdxasync 함수이므로 Server Component에서 await로 바로 호출할 수 있다. 컴파일된 결과는 JSX이므로 그대로 렌더링하면 된다.

커스텀 MDX 컴포넌트

MDX에서 사용하는 HTML 엘리먼트를 커스텀할 수 있다. 나는 이미지와 링크를 커스텀했다:

src/components/mdx/MdxComponents.tsx
function MdxImage(props) {
  return (
    <img {...props} alt={props.alt ?? ''} loading="lazy" />
  )
}
 
function MdxLink(props) {
  const isExternal = props.href?.startsWith('http')
  return (
    <a
      {...props}
      target={isExternal ? '_blank' : undefined}
      rel={isExternal ? 'noopener noreferrer' : undefined}
    />
  )
}
 
export const mdxComponents = {
  img: MdxImage,
  a: MdxLink,
}

외부 링크는 자동으로 새 탭에서 열리고 noopener noreferrer가 추가된다.

빌드 결과

$ pnpm build
 
Route (app)                          Size  First Load JS
 /                               165 B      106 kB
 /_not-found                     125 B      102 kB
 /posts/[slug]                   125 B      102 kB
 /posts/blog-setup
 /posts/blog-theme
 /posts/blog-pages
 
  (Static)   prerendered as static content
  (SSG)      prerendered as static HTML

모든 페이지가 정적으로 생성되고, First Load JS도 100KB 정도로 가볍다.

관심 있을 만한 포스트