Next.js 15로 개인 블로그 만들기 — 프로젝트 셋업

7 min read
Next.js블로그Tailwind CSS

왜 직접 만들었나

Velog, Tistory 같은 플랫폼도 좋지만, 개발자라면 한 번쯤은 자기만의 블로그를 만들어보고 싶은 욕심이 있다. 나도 그랬다.

직접 만들면 좋은 점이 몇 가지 있다:

  • 커스터마이징이 자유롭다 — 디자인, 기능, 레이아웃 전부 내 마음대로
  • 기술 학습이 된다 — SSG, MDX, SEO 등 실전 경험
  • 포트폴리오가 된다 — "이 블로그 자체가 프로젝트"
  • 콘텐츠 소유권 — 플랫폼 종속 없이 마크다운 파일로 관리

기술 스택 선정

고민 끝에 선택한 스택은 다음과 같다:

기술선정 이유
Next.js 15 (App Router)RSC 지원, 정적 빌드, 파일 기반 라우팅
Tailwind CSS v4CSS-first 설정, 빠른 프로토타이핑
next-mdx-remote/rscRSC 호환 MDX 렌더링
rehype-pretty-code빌드 타임 코드 하이라이팅, 듀얼 테마
next-themes다크/라이트 모드 전환
gray-matterfrontmatter 파싱
GitHub Pages무료 호스팅, GitHub Actions 자동 배포

DB 없이 로컬 MDX 파일만으로 운영하는 구조를 선택했다. 콘텐츠를 git으로 관리할 수 있고, 빌드 타임에 정적 HTML로 변환되니 성능도 좋다.

프로젝트 초기 셋업

package.json 생성

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 postcss

Next.js 설정

정적 빌드(SSG)를 위해 output: 'export'를 설정한다. GitHub Pages로 배포할 예정이라 basePath도 미리 잡아두었다.

next.config.mjs
const nextConfig = {
  output: 'export',
  basePath: '/tera-log',
  images: {
    unoptimized: true,
  },
}
 
export default nextConfig

output: 'export'를 설정하면 next buildout/ 디렉토리에 정적 파일이 생성된다. 서버 없이 어디서든 호스팅 가능.

Tailwind CSS v4 설정

v4부터는 tailwind.config.js가 필요 없다. PostCSS 플러그인만 설정하면 된다.

postcss.config.mjs
export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}

autoprefixer도 Tailwind v4에 내장되어 별도 설치가 필요 없다.

TypeScript 설정

tsconfig.json
{
  "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 데이터 레이어

타입 정의

먼저 포스트의 타입을 정의했다:

src/types/post.ts
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를 파싱하는 함수들을 만들었다:

src/lib/posts.ts
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 컴파일 함수:

src/lib/mdx.ts
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 — 코드 블록 구문 강조. 듀얼 테마로 다크/라이트 모두 지원

관심 있을 만한 포스트