Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지
메인 페이지: 카드 그리드
블로그의 첫인상은 메인 페이지에서 결정된다. Velog처럼 깔끔한 카드 그리드로 구성하기로 했다.
PostCard 컴포넌트
각 카드에 표시할 정보는 다음과 같다:
- 썸네일 이미지 (없으면 기본 플레이스홀더)
- 제목
- 설명 (2줄 제한)
- 태그
- 작성일 + 읽기 시간
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>
)
}호버 시 두 가지 효과를 넣었다:
hover:-translate-y-1— 카드가 살짝 위로 떠오르는 효과group-hover:scale-105— 썸네일이 부드럽게 확대
line-clamp-2는 설명이 길어도 2줄까지만 보여주고 말줄임표 처리를 해준다.
반응형 그리드
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열
홈 페이지
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로 빌드 타임에 모든 포스트의 경로를 미리 생성한다:
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>
)
}compileMdx는 async 함수이므로 Server Component에서 await로 바로 호출할 수 있다. 컴파일된 결과는 JSX이므로 그대로 렌더링하면 된다.
커스텀 MDX 컴포넌트
MDX에서 사용하는 HTML 엘리먼트를 커스텀할 수 있다. 나는 이미지와 링크를 커스텀했다:
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 정도로 가볍다.
관심 있을 만한 포스트
Next.js 15로 개인 블로그 만들기 — 프로젝트 셋업
왜 직접 블로그를 만들었는지, 기술 스택 선정 이유와 프로젝트 초기 구성까지. Next.js 15 + Tailwind CSS v4 + MDX 기반 블로그의 시작.
Next.js 블로그 만들기 — 스크롤 프로그레스 바와 Canvas 렌더링 이슈 해결
스크롤 진행률 프로그레스 바 구현과 Canvas 커서 효과가 GNB backdrop-blur와 충돌하며 발생한 깜빡임 이슈 해결기.
Next.js 블로그 만들기 — TOC와 커서 효과로 디테일 살리기
IntersectionObserver 기반 TOC(Table of Contents)와 Canvas 커서 트레일 효과 구현기. 스크롤 하이라이팅, fixed 레이아웃 처리까지.
Next.js 블로그 만들기 — giscus로 댓글 기능 추가
서버 없이 GitHub Discussions 기반 댓글 시스템 giscus를 Next.js 블로그에 연동하기. 다크모드 자동 전환까지.
Next.js 블로그 만들기 — 다크모드와 Tailwind CSS v4 테마
Tailwind CSS v4의 CSS-first 설정과 next-themes를 활용한 다크/라이트 모드 구현. 커스텀 프로퍼티 기반 테마 시스템 구축기.