지난 시간에는 데이터 모델과 폴더 구조를 잡고 첫 MDX 글들을 만들었습니다. 이번에는 진짜 화면을 그립니다 — 홈에 글 목록, /posts/[slug]에 글 상세, 두 페이지를 완성하는 게 목표입니다.
글 목록 페이지
홈(/)에 글 목록을 그립시다. Server Component이므로 fs 모듈을 그대로 사용할 수 있어요.
src/app/page.js:
import Link from 'next/link';
import { getAllPosts } from './lib/posts';
export default function HomePage() {
const posts = getAllPosts();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>블로그</h1>
{posts.length === 0 ? (
<p>아직 글이 없습니다.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<li key={post.slug} style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
<h2 style={{ margin: 0 }}>
<Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
</h2>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p>{post.frontmatter.description}</p>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(post.frontmatter.tags ?? []).map(tag => (
<span key={tag} style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px' }}>
{tag}
</span>
))}
</div>
</li>
))}
</ul>
)}
</main>
);
}저장하고 http://localhost:3000에 접속하면 posts/에 만들어둔 글들이 목록으로 나타납니다 (draft는 제외). 인터넷이 빠르든 느리든 첫 화면이 즉시 보일 거예요 — Server Component가 빌드/요청 시점에 미리 HTML을 만들어 보내기 때문입니다.
콘솔이 어디 찍히는지 확인하기
지난 시리즈에서 강조했던 멘탈 모델을 다시 확인해봅시다. 파일 맨 위에 console.log를 박아보세요.
import Link from 'next/link';
import { getAllPosts } from './lib/posts';
export default function HomePage() {
const posts = getAllPosts();
console.log('포스트 개수:', posts.length);
// ...
}브라우저 콘솔이 아니라 dev 서버 터미널에 찍힙니다. Server Component가 서버에서 실행된다는 게 다시 한번 명확해지죠.
글 상세 페이지 — 동적 라우트
/posts/hello-world 같은 URL이 동작해야 합니다. App Router에서는 폴더명을 [param]으로 만들어 동적 라우트를 표현합니다.
src/app/posts/[slug]/page.js:
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllSlugs } from '../../lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';
export async function generateStaticParams() {
return getAllSlugs().map(slug => ({ slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post || post.frontmatter.draft) {
notFound();
}
const { content } = await compileMDX({
source: post.content,
});
return (
<article style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>{post.frontmatter.title}</h1>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p style={{ color: '#555' }}>{post.frontmatter.description}</p>
<hr />
{content}
</article>
);
}핵심 요소를 풀어보겠습니다.
params는 Promise
const { slug } = await params;Next.js 15부터 params는 Promise라서 await을 거쳐야 값을 꺼낼 수 있습니다. 이전 버전을 따라하는 자료에서는 그냥 params.slug처럼 썼을 수 있는데, 최신 기준으로는 await params가 정석입니다.
compileMDX로 본문 변환
const { content } = await compileMDX({
source: post.content,
});next-mdx-remote/rsc의 compileMDX가 마크다운 본문 문자열을 React 컴포넌트로 변환해줍니다. 결과의 content는 실제 JSX 트리이고, 그대로 화면에 그릴 수 있어요.
이 변환은 서버에서 일어납니다. 브라우저에 가는 건 변환된 HTML뿐이고, MDX 컴파일러 자체는 클라이언트 번들에 포함되지 않습니다 — Server Component의 또 하나의 강점이에요.
notFound() — 글이 없을 때
if (!post || post.frontmatter.draft) {
notFound();
}notFound()는 Next.js가 제공하는 함수로, 호출 즉시 페이지 렌더를 중단하고 404 화면을 보여줍니다. draft인 글에 직접 URL로 접근해도 404로 처리되니 의도치 않게 노출되지 않아요.
404 화면을 커스터마이즈하려면 src/app/not-found.js를 추가하면 됩니다 (지금은 Next.js 기본 화면 사용).
generateStaticParams — 빌드 시점 정적 생성
export async function generateStaticParams() {
return getAllSlugs().map(slug => ({ slug }));
}이 함수가 있으면 Next.js는 빌드 시점에 모든 슬러그에 대해 페이지를 미리 생성합니다. 결과적으로 모든 글이 정적 HTML로 변환되어 CDN에 올라가요. 런타임에 fs를 다시 읽지도, MDX를 다시 컴파일하지도 않아 매우 빠릅니다.
이 함수가 없으면 페이지는 동적으로 렌더됩니다 — 매 요청마다 fs를 읽고 컴파일하므로 좀 더 느리지만 새 글 추가가 즉시 반영됩니다.
블로그처럼 글이 자주 바뀌지 않는 경우 정적 생성이 거의 항상 더 좋은 선택입니다. 글을 추가하면 다시 빌드하면 되니까요(Vercel은 git push 시 자동).
정적 생성과 동적 렌더의 선택은 한 번에 정하지 않아도 됩니다. App Router는 두 모드를 자동으로 잘 결정해줍니다 — generateStaticParams가 있고 페이지 안에서 동적 함수(쿠키 읽기, 헤더 읽기, searchParams 사용 등)를 안 쓰면 정적, 그 외엔 동적. 지금은 "이렇게 쓰면 정적이다" 정도만 알아두세요.
동작 확인
저장하고 다음을 확인합니다.
- http://localhost:3000 — 글 목록이 발행일 내림차순으로 표시
- 각 글 제목 클릭 → 상세 페이지로 이동
- 마크다운 본문이 HTML로 잘 렌더되는지
- http://localhost:3000/posts/draft-not-shown — 404 화면
- http://localhost:3000/posts/존재하지않는글 — 404 화면
- 페이지 소스 보기(우클릭 → 페이지 소스) — 본문이 이미 HTML로 들어 있음 (CSR이었다면 빈 div만 있을 부분)
마지막 6번이 인상적인 부분입니다. SEO 친화적이고, 검색 엔진이 콘텐츠를 그대로 인덱싱할 수 있어요.
마크다운 확장 — remark-gfm
기본 마크다운만으로는 테이블, 체크박스, URL 자동 링크 같은 GitHub 스타일 문법이 안 됩니다. remark-gfm을 추가합시다.
npm install remark-gfmcompileMDX 호출에 옵션을 추가합니다.
import remarkGfm from 'remark-gfm';
// ...
const { content } = await compileMDX({
source: post.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
},
},
});이제 글 본문에 다음 같은 표를 써도 잘 렌더됩니다.
| 이름 | 역할 |
|---|---|
| Server Component | 서버에서 실행 |
| Client Component | 브라우저에서도 실행 |체크박스도:
- [x] 1단계 완료
- [ ] 2단계 (진행 중)
- [ ] 3단계코드 블록 신택스 하이라이팅 — rehype-pretty-code
기술 블로그라면 코드 블록 하이라이팅이 거의 필수입니다.
npm install rehype-pretty-code shikiimport remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
// ...
const { content } = await compileMDX({
source: post.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-light' }],
],
},
},
});이제 본문 안 코드 블록에 색이 입혀집니다. 언어를 명시(```js)하면 그 언어 문법에 맞춰 하이라이트되고요.
조금 더 다듬으려면:
- 코드 블록 위에 파일명/캡션 넣기 (
```js title="example.js") - 언어별 아이콘 표시 (이 사이트가 하는 방식, 별도 CSS 필요)
이 시리즈에서는 기본 옵션만 사용합니다. 실제로 다듬는 건 본인 취향대로.
레이아웃 — 헤더 추가
지금은 모든 페이지가 본문만 있고 공통 영역이 없습니다. layout.js에 간단한 헤더를 둡시다.
src/app/layout.js:
import Link from 'next/link';
import './globals.css';
export const metadata = {
title: '나의 블로그',
description: 'Next.js로 만든 블로그',
};
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<header style={{ padding: '12px 24px', background: '#222', color: '#fff', display: 'flex', gap: '16px' }}>
<Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 'bold' }}>홈</Link>
<Link href="/tags" style={{ color: '#fff', textDecoration: 'none' }}>태그</Link>
<Link href="/search" style={{ color: '#fff', textDecoration: 'none' }}>검색</Link>
</header>
{children}
</body>
</html>
);
}/tags와 /search는 #3에서 만들 페이지입니다. 미리 링크만 걸어두고, 클릭하면 지금은 404가 뜨겠죠. 다음 글에서 채울 거예요.
작은 리팩터 — PostCard 컴포넌트
홈 페이지의 글 카드 부분이 슬슬 길어집니다. 별도 컴포넌트로 분리합시다.
src/app/PostCard.jsx:
import Link from 'next/link';
export default function PostCard({ post }) {
return (
<li style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
<h2 style={{ margin: 0 }}>
<Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
</h2>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p>{post.frontmatter.description}</p>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(post.frontmatter.tags ?? []).map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px', textDecoration: 'none', color: '#333' }}
>
#{tag}
</Link>
))}
</div>
</li>
);
}홈 페이지가 짧아집니다.
import { getAllPosts } from './lib/posts';
import PostCard from './PostCard';
export default function HomePage() {
const posts = getAllPosts();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>블로그</h1>
{posts.length === 0 ? (
<p>아직 글이 없습니다.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
)}
</main>
);
}태그 클릭 시 /tags/[tag]로 이동하도록 미리 링크를 박아두었습니다 (실제 동작은 #3에서).
동작 확인 — 종합
다음을 확인하면 #2 마무리:
- 홈 페이지에 글 목록이 잘 표시
- 각 글 카드에 제목/날짜/요약/태그
- 글 상세 페이지의 마크다운 본문이 잘 렌더 (코드 블록 하이라이팅 포함)
- 표(remark-gfm)가 동작
- draft인 글은 목록에 없고 직접 접근 시 404
- 헤더의 "홈" 링크가 항상 동작 (다른 두 링크는 #3에서)
마무리
이번 글에서는 블로그의 두 핵심 페이지를 완성했습니다.
- 홈 — Server Component가 fs로 글 목록 읽고 표시
- 글 상세 — 동적 라우트 +
compileMDX로 본문 컴파일 generateStaticParams로 빌드 시점 정적 생성notFound()로 없는 글/draft 처리remark-gfm,rehype-pretty-code플러그인 도입- 작은 리팩터로
PostCard분리
다음 글인 "Next.js로 블로그 만들기 #3 태그와 검색"에서는 헤더에 미리 걸어둔 /tags와 /search를 채웁니다 — 태그별 글 모음, 그리고 URL 쿼리 파라미터를 활용한 검색 기능을 만들어보겠습니다.