개발자라면 한 번쯤 내 블로그를 직접 만들어볼까 하는 생각을 합니다. Velog나 Tistory 같은 플랫폼도 훌륭하지만, 디자인과 기능을 처음부터 끝까지 내 마음대로 정할 수 있다는 게 자체 블로그의 장점이고, 그러다 보면 결과물이 포트폴리오도 됩니다.
Next.js 16의 App Router와 MDX를 조합해 블로그를 구축한 과정을 설계 결정부터 배포까지 순서대로 적어둡니다.
1. 왜 Next.js + MDX인가?
먼저 두 도구를 고른 이유부터 짚고 가겠습니다.
App Router는 기존 Pages Router와 달리 React Server Components(RSC)를 기본값으로 씁니다. 블로그처럼 정적 콘텐츠가 많은 프로젝트에서는 이 차이가 꽤 큽니다. 서버 컴포넌트에서 fs.readFileSync로 파일 시스템에 직접 접근할 수 있고, 그만큼 클라이언트로 내려보내는 번들이 가벼워집니다. 게다가 동적 라우팅이 폴더 구조 그대로 표현돼서 URL과 파일이 직관적으로 대응됩니다.
여기에 MDX를 더하면 Markdown 안에서 JSX 컴포넌트를 그대로 쓸 수 있습니다. 일반 Markdown보다 표현력이 풍부하면서도 콘텐츠는 여전히 파일로 남아 CMS 없이 관리됩니다. 서버에서 파일을 읽어 가볍게 그리고, 콘텐츠는 파일로 두는 이 조합이 블로그의 뼈대가 됐습니다.
2. 프로젝트 구조 설계
code 복사
src/
├── app/
│ ├── posts/
│ │ ├── page.tsx # 글 목록 페이지
│ │ └── [category]/[id]/
│ │ └── page.tsx # 글 상세 페이지
│ └── layout.tsx # 루트 레이아웃
├── posts/ # MDX 파일 저장소
│ ├── NextJS/
│ │ └── 1.mdx
│ └── ReactNative/
│ └── 1.mdx
└── lib/
└── postManagement/ # 포스트 유틸리티
├── getPostList.ts
├── getPostDetail.ts
└── types.ts
설계의 핵심은 카테고리를 폴더 구조 그 자체로 관리한다는 것입니다. src/posts/[카테고리명]/[번호].mdx 형태로 파일을 넣기만 하면 카테고리가 자동으로 인식됩니다. 별도의 설정 파일이나 데이터베이스 없이 파일을 두는 위치가 곧 메타데이터가 됩니다.
3. Frontmatter 파싱: gray-matter
각 MDX 파일 맨 위에는 YAML 형식의 메타데이터를 적습니다.
yaml 복사
---
title : "포스트 제목"
date : 2026-03-01
tags : [ "Next.js" , "MDX" ]
---
이 블록을 본문과 분리해 파싱하는 일은 gray-matter가 맡습니다.
typescript 복사
import matter from 'gray-matter' ;
import { readFileSync } from 'fs' ;
const file = readFileSync ( `${ POSTS_PATH }/${ category }/${ id }.mdx` );
const { data , content } = matter (file);
// data → { title, date, tags }
// content → 본문 마크다운 문자열
data에는 메타데이터가, content에는 본문이 깔끔하게 나뉘어 들어옵니다. 목록 페이지에서는 data만 모아 카드로 그리고, 상세 페이지에서는 content를 렌더링하면 됩니다.
4. MDX 렌더링 파이프라인
본문 렌더링은 next-mdx-remote로 서버 컴포넌트에서 처리합니다. 여기에 플러그인을 얹는 만큼 표현력이 늘어납니다.
tsx 복사
import { MDXRemote } from 'next-mdx-remote/rsc' ;
import remarkGfm from 'remark-gfm' ;
import remarkBreaks from 'remark-breaks' ;
import rehypePrettyCode from 'rehype-pretty-code' ;
import rehypeSlug from 'rehype-slug' ;
< MDXRemote
source = { content }
options = { {
mdxOptions: {
remarkPlugins: [remarkGfm, remarkBreaks],
rehypePlugins: [
[rehypePrettyCode, {
theme: {
dark: 'github-dark-dimmed' ,
light: 'github-light' ,
},
}],
rehypeSlug,
],
},
} }
/>
각 플러그인이 맡는 역할은 다음과 같습니다.
플러그인 역할 remarkGfm 테이블, 취소선 등 GitHub Flavored Markdown 지원 remarkBreaks 한 줄 줄바꿈을 <br>로 변환 rehypePrettyCode Shiki 기반 코드 하이라이팅, 라이트/다크 테마 지원 rehypeSlug heading에 자동 id 부여 (목차 연동용)
remark는 Markdown 단계를, rehype는 변환된 HTML 단계를 손본다고 이해하면 흐름이 잡힙니다. 그래서 배열에 적는 순서가 곧 처리 순서이고, 순서가 결과를 바꾸기도 합니다.
5. 동적 라우팅: [category]/[id]
App Router에서는 폴더명에 대괄호를 씌워 동적 세그먼트를 만듭니다.
tsx 복사
// src/app/posts/[category]/[id]/page.tsx
const Page = async ({ params } : { params : Promise <{ category : string ; id : string }> }) => {
const { category , id } = await params;
const postDetail = getPostDetail (category, id);
return (
< article >
< h1 > { postDetail.title } </ h1 >
< MDXRemote source = { postDetail.content } />
</ article >
);
};
덕분에 /posts/NextJS/1 같은 URL이 파일 시스템의 폴더 구조와 1:1로 맞아떨어집니다. 라우팅 설정을 따로 관리할 필요 없이, 파일을 어디에 두느냐가 곧 주소가 됩니다.
6. 포스트 목록 조회: glob 패턴 활용
typescript 복사
import { sync } from 'glob' ;
const getPostPaths = ( category ?: string ) => {
return sync ( `${ POSTS_PATH }/${ category || '**'}/**/*.mdx` );
};
glob의 ** 패턴으로 모든 카테고리의 MDX 파일을 한 번에 긁어오고, 카테고리가 지정되면 해당 폴더만 좁혀 검색합니다. 함수 하나로 전체 목록과 카테고리별 목록을 모두 처리할 수 있습니다.
7. 카테고리 필터링
필터는 URL 쿼리스트링을 기준으로 구현했습니다. ?category=NextJS 같은 형태이고, 서버 컴포넌트에서 searchParams를 받아 곧장 목록을 좁힙니다.
tsx 복사
const Page = async ({ searchParams } : { searchParams : Promise <{ category ?: string }> }) => {
const { category } = await searchParams;
const postList = getPostList (category);
// ...
};
필터 상태를 URL에 담아 두면 새로고침이나 링크 공유에도 그대로 유지된다는 장점이 있습니다. 클라이언트 쪽에서는 useRouter와 useSearchParams로 카테고리가 바뀔 때 URL만 갱신해주면 됩니다.
8. 코드 블록 스타일링
rehype-pretty-code가 만들어내는 HTML 구조에 맞춰 CSS를 따로 다듬었습니다. 라이트/다크 전환은 CSS 변수로 처리합니다.
css 복사
code [ data-theme *= ' ' ],
code [ data-theme *= ' ' ] span {
color : var ( --shiki-light );
background-color : var ( --shiki-light-bg );
}
.dark code [ data-theme *= ' ' ],
.dark code [ data-theme *= ' ' ] span {
color : var ( --shiki-dark );
background-color : var ( --shiki-dark-bg );
}
테마별 색상이 토큰마다 CSS 변수로 들어가 있어, 다크모드로 전환해도 코드 색이 자연스럽게 따라옵니다. 인라인 코드는 Notion 스타일의 빨간 텍스트 + 회색 배경으로 마감했습니다.
9. 배포 및 SEO
마지막은 검색 노출입니다. Next.js의 metadata API로 OG 태그와 Twitter 카드를 설정하고, sitemap.ts로 사이트맵을 자동 생성합니다.
typescript 복사
export const metadata : Metadata = {
metadataBase: new URL ( 'https://www.inak.dev' ),
title: '이낙 개발 블로그' ,
openGraph: { /* ... */ },
twitter: { /* ... */ },
};
metadataBase를 한 번 잡아두면 OG 이미지나 canonical 경로를 상대 경로로 적어도 알아서 절대 URL로 채워줘서 편합니다.
마무리
직접 만들면서 가장 좋았던 건 글을 쓰는 행위 자체가 기술 학습이 된다는 점이었습니다. MDX 파일 하나만 추가하면 곧바로 포스트가 되는 구조라 글쓰기 진입장벽도 눈에 띄게 낮아졌습니다.
비슷한 걸 만들 계획이라면 폴더 구조를 카테고리로 쓰는 부분부터 정해보시길 권합니다. 이 결정 하나가 목록 조회와 동적 라우팅, 카테고리 필터를 전부 단순하게 만들어줬습니다.