Next.js 15로 개인 블로그 만들기 — 프로젝트 셋업
왜 직접 만들었나
Velog, Tistory 같은 플랫폼도 좋지만, 개발자라면 한 번쯤은 자기만의 블로그를 만들어보고 싶은 욕심이 있다. 나도 그랬다.
직접 만들면 좋은 점이 몇 가지 있다:
- 커스터마이징이 자유롭다 — 디자인, 기능, 레이아웃 전부 내 마음대로
- 기술 학습이 된다 — SSG, MDX, SEO 등 실전 경험
- 포트폴리오가 된다 — "이 블로그 자체가 프로젝트"
- 콘텐츠 소유권 — 플랫폼 종속 없이 마크다운 파일로 관리
기술 스택 선정
고민 끝에 선택한 스택은 다음과 같다:
| 기술 | 선정 이유 |
|---|---|
| Next.js 15 (App Router) | RSC 지원, 정적 빌드, 파일 기반 라우팅 |
| Tailwind CSS v4 | CSS-first 설정, 빠른 프로토타이핑 |
| next-mdx-remote/rsc | RSC 호환 MDX 렌더링 |
| rehype-pretty-code | 빌드 타임 코드 하이라이팅, 듀얼 테마 |
| next-themes | 다크/라이트 모드 전환 |
| gray-matter | frontmatter 파싱 |
| GitHub Pages | 무료 호스팅, GitHub Actions 자동 배포 |
DB 없이 로컬 MDX 파일만으로 운영하는 구조를 선택했다. 콘텐츠를 git으로 관리할 수 있고, 빌드 타임에 정적 HTML로 변환되니 성능도 좋다.
프로젝트 초기 셋업
package.json 생성
{
"name": "blog",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
}의존성 설치
# 런타임 의존성
pnpm add next react react-dom next-mdx-remote next-themes \
gray-matter reading-time dayjs rehype-pretty-code remark-gfm shiki
# 개발 의존성
pnpm add -D typescript @types/node @types/react @types/react-dom \
tailwindcss @tailwindcss/postcss postcssNext.js 설정
정적 빌드(SSG)를 위해 output: 'export'를 설정한다. GitHub Pages로 배포할 예정이라 basePath도 미리 잡아두었다.
const nextConfig = {
output: 'export',
basePath: '/tera-log',
images: {
unoptimized: true,
},
}
export default nextConfig
output: 'export'를 설정하면next build시out/디렉토리에 정적 파일이 생성된다. 서버 없이 어디서든 호스팅 가능.
Tailwind CSS v4 설정
v4부터는 tailwind.config.js가 필요 없다. PostCSS 플러그인만 설정하면 된다.
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}autoprefixer도 Tailwind v4에 내장되어 별도 설치가 필요 없다.
TypeScript 설정
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
}
}@/* path alias를 설정해서 import { something } from '@/lib/utils' 형태로 깔끔하게 import할 수 있도록 했다.
프로젝트 구조
blog/
├── content/ # MDX 포스트 (파일명 = URL slug)
├── public/images/ # 정적 이미지
├── src/
│ ├── app/ # App Router 페이지
│ ├── components/ # React 컴포넌트
│ ├── lib/ # 유틸리티 (MDX 파싱, 데이터 로딩)
│ └── types/ # TypeScript 타입 정의
├── next.config.mjs
├── postcss.config.mjs
└── tsconfig.json
핵심은 content/ 디렉토리다. 여기에 .mdx 파일을 하나 만들면 그게 바로 블로그 포스트가 된다. 파일명이 URL slug가 되므로:
content/blog-setup.mdx → /posts/blog-setup
MDX 데이터 레이어
타입 정의
먼저 포스트의 타입을 정의했다:
export interface PostFrontmatter {
readonly title: string
readonly description: string
readonly date: string
readonly tags: readonly string[]
readonly thumbnail?: string
}
export interface PostMeta extends PostFrontmatter {
readonly slug: string
readonly readingTime: string
}
export interface Post extends PostMeta {
readonly content: string
}readonly를 적극 사용해서 불변성을 보장했다. 데이터가 여기저기서 변경되면 디버깅이 어려워지기 때문이다.
포스트 읽기
파일 시스템에서 MDX 파일을 읽고 frontmatter를 파싱하는 함수들을 만들었다:
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import readingTime from 'reading-time'
import type { Post, PostMeta } from '@/types/post'
const CONTENT_DIR = path.join(process.cwd(), 'content')
export function getAllPostMetas(): readonly PostMeta[] {
const files = fs.readdirSync(CONTENT_DIR)
.filter((file) => file.endsWith('.mdx'))
const posts = files.map((file) => {
const slug = file.replace('.mdx', '')
const fileContent = fs.readFileSync(
path.join(CONTENT_DIR, file), 'utf-8'
)
const { data, content } = matter(fileContent)
const stats = readingTime(content)
return {
slug,
title: data.title,
description: data.description,
date: data.date,
tags: data.tags ?? [],
thumbnail: data.thumbnail,
readingTime: stats.text,
}
})
// 최신순 정렬
return posts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
}여기서 핵심적인 설계 결정이 있다: 리스트 페이지에서는 gray-matter만 사용하고, MDX 컴파일은 상세 페이지에서만 수행한다. MDX 컴파일은 무거운 작업이기 때문에, 목록에서는 frontmatter만 빠르게 읽어오는 것이 효율적이다.
MDX 컴파일
상세 페이지에서 사용할 MDX 컴파일 함수:
import { compileMDX } from 'next-mdx-remote/rsc'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
export async function compileMdx(source, components) {
const { content } = await compileMDX({
source,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, {
theme: {
dark: 'github-dark-dimmed',
light: 'github-light',
},
keepBackground: false,
}],
],
},
},
components,
})
return content
}- remarkGfm — GFM 문법(테이블, 체크리스트 등) 지원
- rehypeSlug — heading에 자동으로 id 부여 (TOC에서 활용)
- rehypePrettyCode — 코드 블록 구문 강조. 듀얼 테마로 다크/라이트 모두 지원
관심 있을 만한 포스트
Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지
Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.
Next.js 블로그 만들기 — 다크모드와 Tailwind CSS v4 테마
Tailwind CSS v4의 CSS-first 설정과 next-themes를 활용한 다크/라이트 모드 구현. 커스텀 프로퍼티 기반 테마 시스템 구축기.
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 블로그에 연동하기. 다크모드 자동 전환까지.