Next.js App Router에서 MDX 블로그 만들 때 헷갈리는 것들
2026.04.20
App Router로 블로그 만들면서 헤맨 6가지
이 블로그를 Next.js App Router로 만들면서 부딪힌 함정들이 있다. 검색하면 Pages Router 가이드가 더 많이 나오는데, App Router는 결이 꽤 달라서 그대로 따라가면 깨진다.
이 글은 그 차이를 한 곳에 정리한다. App Router로 MDX 블로그를 처음 만드는 사람이 똑같이 헤매지 않도록.
함정 1: Pages Router 가이드를 그대로 따라간다
가장 큰 함정. 인터넷에 있는 Next.js MDX 가이드는 대부분 Pages Router 기준이다. 그래서 이런 코드를 보게 된다.
// pages/blog/[slug].tsx (Pages Router)
import { GetStaticProps, GetStaticPaths } from "next";
export const getStaticPaths: GetStaticPaths = async () => { ... };
export const getStaticProps: GetStaticProps = async () => { ... };App Router에서는 이 패턴이 통째로 사라졌다. getStaticPaths 대신 generateStaticParams, getStaticProps 대신 그냥 컴포넌트가 async function이다.
// app/blog/[slug]/page.tsx (App Router)
export async function generateStaticParams() {
return posts.map((p) => ({ slug: p.slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}같은 일을 하지만 모양이 다르다. App Router 가이드가 아니면 코드를 그대로 못 쓴다. 검색할 때 "App Router"를 키워드에 꼭 넣는다.
함정 2: dynamic route가 빌드 타임에 안 잡힌다
app/blog/[slug]/page.tsx를 만들고 generateStaticParams로 슬러그 목록을 반환했는데, 빌드 시 정적 페이지가 안 만들어지는 경우가 있다.
원인은 보통 dynamic 또는 revalidate 설정이 잘못된 거다.
// 빌드 타임에 정적 생성 (원하는 동작)
export const dynamic = "force-static";
export const dynamicParams = false;
export async function generateStaticParams() {
return posts.map((p) => ({ slug: p.slug }));
}dynamicParams = false를 안 넣으면, 목록에 없는 슬러그가 들어와도 동적으로 생성하려고 한다. 블로그처럼 글이 정해져 있는 경우는 명시적으로 막는 게 안전하다.
dynamic = "force-static"은 이 라우트가 무조건 정적이어야 함을 강제한다. 데이터 호출 패턴이 SSG가 아닌 것처럼 보이면 Next가 자동으로 SSR로 분류하는데, 이걸 막는다.
함정 3: MDX 파싱 도구 선택
Next.js는 공식 @next/mdx를 제공하지만, App Router 블로그에서는 잘 안 맞는다. next/mdx는 빌드 타임에 import 기반으로 동작하는데, 콘텐츠 디렉토리의 모든 MDX 파일을 동적으로 파싱하려면 다른 방법이 필요하다.
내가 쓰는 조합:
next-mdx-remote: 빌드/런타임에 MDX 문자열을 React로 변환gray-matter: frontmatter 파싱rehype-pretty-code+shiki: 코드 하이라이팅remark-gfm: GitHub 스타일 마크다운(테이블, 체크박스)
설치.
npm install next-mdx-remote gray-matter rehype-pretty-code shiki remark-gfm기본 사용:
import { MDXRemote } from "next-mdx-remote/rsc";
import matter from "gray-matter";
import rehypePrettyCode from "rehype-pretty-code";
import remarkGfm from "remark-gfm";
import fs from "node:fs";
async function getPost(slug: string) {
const raw = fs.readFileSync(`content/blog/${slug}.mdx`, "utf-8");
const { data, content } = matter(raw);
return { meta: data, content };
}
export default async function Page({ params }) {
const { meta, content } = await getPost(params.slug);
return (
<article>
<h1>{meta.title}</h1>
<MDXRemote
source={content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrettyCode, { theme: "github-light" }]],
},
}}
/>
</article>
);
}next-mdx-remote/rsc를 쓰면 서버 컴포넌트에서 직접 사용할 수 있다. 클라이언트 번들에 MDX 파서가 안 실린다.
함정 4: 메타데이터를 어디에 박는가
App Router에서 페이지별 메타데이터는 generateMetadata 함수로 정의한다.
import type { Metadata } from "next";
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.meta.title,
description: post.meta.description,
openGraph: {
title: post.meta.title,
description: post.meta.description,
type: "article",
publishedTime: post.meta.date,
},
twitter: {
card: "summary_large_image",
title: post.meta.title,
description: post.meta.description,
},
};
}Pages Router에서 <Head> 컴포넌트로 박던 걸, 함수로 반환한다. 서버 사이드에서만 동작하니 SEO 안전.
og:image는 metadataBase를 root layout에 두면 상대 경로로 처리된다.
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL("https://joowonkoh.dev"),
// ...
};이게 빠지면 og:image가 절대 경로로 잡히지 않아서 Slack/Twitter에서 미리보기가 깨진다.
함정 5: 클라이언트 컴포넌트와 서버 컴포넌트의 경계
MDX 안에서 React 컴포넌트를 쓰면 그 컴포넌트가 서버/클라이언트 어느 쪽이냐가 중요해진다. 인터랙션(클릭, 상태)이 있는 컴포넌트는 클라이언트 컴포넌트여야 한다.
// components/InteractiveCard.tsx
"use client";
export default function InteractiveCard() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}"use client" 지시어가 맨 위에 있어야 한다. 빠뜨리면 빌드 시 "useState is not a function" 같은 에러가 난다.
상호작용이 없는 시각화 카드(예: GitCommandCard)는 서버 컴포넌트로 둔다. 클라이언트 번들이 가벼워진다.
함정 6: 빌드 시 콘텐츠 디렉토리를 못 찾는다
배포 환경에서 빌드 시 fs.readFileSync가 파일을 못 찾는 경우가 있다.
const raw = fs.readFileSync("content/blog/foo.mdx"); // 빌드 시 에러이유는 작업 디렉토리(cwd)가 빌드 환경에서 다를 수 있어서다. process.cwd()를 명시한다.
import path from "node:path";
const filePath = path.join(process.cwd(), "content/blog", `${slug}.mdx`);
const raw = fs.readFileSync(filePath, "utf-8");Cloudflare Pages 같은 환경에서는 이게 더 까다롭다. Cloudflare 배포 글에서 다뤘다.
디렉토리 구조 추천
내가 쓰는 구조:
app/
blog/
[slug]/
page.tsx # 글 페이지
page.tsx # 글 목록
layout.tsx
content/
blog/
2026-04-01-foo.mdx
2026-04-02-bar.mdx
lib/
posts.ts # MDX 파싱 유틸리티
src/
components/
InteractiveCard.tsx (use client)
StaticCard.tsx콘텐츠를 content/에 분리해두면 빌드 도구나 컴포넌트 코드와 섞이지 않는다. 글이 늘어나도 리포 구조가 깔끔하다.
직접 겪은 일: 일곱 번째 함정은 Suspense였다
여섯 개를 적어놨는데, 나중에 제일 크게 데인 건 목록에 없던 일곱 번째였다.
목록 페이지에서 ?tag=를 읽으려고 useSearchParams를 넣었다. App Router에서 이걸 쓰면 해당 컴포넌트가 Suspense 경계 안으로 들어간다. 그때 fallback을 null로 뒀는데, 그러면 서버가 내보내는 HTML에 글 목록이 통째로 빠진다.
로컬에서는 안 보였다. next start로 열면 화면에 글이 멀쩡히 나온다. 브라우저가 붙고 나서 채워지기 때문이다. 배포한 뒤 실제 HTML을 재보고서야 알았다 — 목록 페이지가 머리말과 꼬리말만 남은 19단어짜리가 되어 있었다.
고치려고 서버 쪽에서 searchParams를 받았더니 이번엔 라우트가 정적에서 동적으로 바뀌었다. 목록을 만드는 함수가 fs로 파일을 읽는데, 이 사이트가 올라가는 Cloudflare의 동적 라우트는 fs가 없는 곳에서 돈다. 함정 하나를 피하려다 다른 함정으로 걸어 들어간 것이다.
결국 페이지는 정적으로 두고, 태그는 브라우저가 붙은 뒤에 주소창에서 읽게 했다. App Router에서 «화면에 보인다»와 «HTML에 있다»는 다른 이야기다. 이 둘을 구분하지 않으면 로컬에서는 영원히 안 보인다.
정리
App Router로 MDX 블로그를 만들 때 마주치는 함정 6가지를 한 줄씩 정리한다.
- Pages Router 가이드를 그대로 따라가지 마라
dynamic = "force-static"+dynamicParams = false로 SSG 강제next-mdx-remote/rsc로 서버 컴포넌트에서 MDX 렌더- 메타데이터는
generateMetadata함수로 - 인터랙티브 컴포넌트만
"use client" - 파일 경로는
process.cwd()기준으로
이 6가지만 알고 시작하면 처음부터 절반쯤 덜 헤맨다. App Router는 패턴만 익히면 Pages Router보다 더 깔끔하다. 다만 그 패턴이 인터넷에 아직 적게 깔려 있어서, 직접 부딪히며 배우는 데 시간이 더 든다. 이 글이 그 시간을 줄여주면 좋겠다.
같이 읽으면 좋은 글
Contentlayer 없이 MDX 블로그 만들기: 직접 파서 짠 후기
Contentlayer 메인테넌스 중단 이후 직접 MDX 파서를 짰다. gray-matter, next-mdx-remote, 타입 안전성, 빌드 캐시까지 — 100줄짜리 자체 콘텐츠 레이어를 만든 과정.
MDXNext.js2026.04.21MDX에서 표 대신 React 컴포넌트로 시각화하기: 설계와 패턴
마크다운 표는 모바일에서 깨진다. MDX의 진짜 가치는 표를 시각화 컴포넌트로 대체하는 데 있다. CompareCard, PlanCard, FlowCard 같은 시각화 컴포넌트의 설계 원칙을 정리한다.
MDX디자인React2026.04.22Cloudflare Pages 배포 시 빌드 캐시 이슈와 해결기
Cloudflare Pages 빌드가 매번 처음부터 도는 문제, 캐시가 적용 안 되는 이유, _next 캐싱 충돌, 무효화 시점까지 — 실제로 부딪힌 빌드 캐시 이슈와 해결법을 정리한다.
Cloudflare배포Next.js2026.04.20