Next.js로 간단한 블로그 만들기
Next.js를 사용해 정적 블로그를 빠르게 구축하는 방법을 소개합니다. 마크다운 파일을 활용한 글 작성, 동적 라우팅, 스타일링까지 쉽게 따라할 수 있습니다.

이런 것을 만들 수 있습니다
이 글을 끝까지 따라 하면, 마크다운으로 글을 작성할 수 있는 완전한 개인 블로그가 완성됩니다. 복잡한 CMS나 데이터베이스 없이도 빠르고 효율적인 블로그를 직접 구축할 수 있습니다.
GitHub에서 완성된 블로그 코드 확인하기 →
주요 특징
- Next.js 15 기반 정적 사이트 생성
- 마크다운(.md) 파일로 간편한 포스트 작성
- 동적 라우팅으로 SEO 최적화
- 반응형 레이아웃과 모던한 디자인
- GitHub Pages나 Vercel로 무료 배포 가능

프로젝트 생성 및 기본 구조 설정
이 섹션에서는 Next.js 기반 블로그 프로젝트의 시작을 위한 두 가지 핵심 단계를 진행합니다.
- Next.js 프로젝트 생성
- 기본 프로젝트 구조 및 레이아웃 추가
아래 명령어를 통해 현재 섹션 완료 코드를 볼 수 있습니다.
git clone git@github.com:sijunnoh/blog-nextjs-blog.git
git checkout section01
1. Next.js 프로젝트 생성
먼저 터미널을 열고 프로젝트를 생성할 디렉토리로 이동합니다. 그 다음 아래 명령어를 실행하여 Next.js 프로젝트를 생성합니다.
npx create-next-app@latest
프로젝트 생성 과정에서 여러 옵션을 선택하게 됩니다. 이 튜토리얼에서는 다음과 같이 설정하겠습니다.
프로젝트 이름: blog-nextjs-blog (원하는 이름으로 변경 가능)TypeScript: Yes (타입 안정성을 위해 권장)ESLint: Yes (코드 품질 관리)Tailwind CSS: Yes (빠른 스타일링)src/ 디렉토리: Yes (코드 구조화)App Router: Yes (Next.js 13+ 권장 방식)Turbopack: Yes (빠른 개발 서버)Import alias: No (기본값 사용)
npm run dev 명령어를 통해 dev server를 실행합니다.
➜ npx create-next-app@latest
Need to install the following packages:
create-next-app@15.4.6
Ok to proceed? (y)
✔ What is your project named? … blog-nextjs-blog
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias [`@/*` by default?] … No / Yes
Creating a new Next.js app in /Users/mech2cs/work/mech2cs/blog/blog-nextjs-blog.2. UI 컴포넌트 추가 - shadcn/ui
이번 섹션에 필요한 UI 컴포넌트를 먼저 추가하겠습니다.
shadcn-ui는 다양한 UI 컴포넌트를 빠르게 적용할 수 있도록 도와주는 툴킷입니다.
Next.js 프로젝트에 최적화되어 있으며, 커스터마이징도 유연하게 할 수 있습니다.
아래 명령어 실행하여 button, dropdown-menu, 그리고 sheet 컴포넌트를 추가합니다.
npx shadcn@latest add
명령어를 입력하면 프로젝트 설정에 따라 몇 가지 질문을 받게 되며, 기본값으로 진행해도 괜찮습니다. 설정이 완료되면 사용할 컴포넌트를 선택할 수 있습니다.

3. 페이지 구조 및 헤더 컴포넌트 생성
블로그의 기본 구조를 만들기 위해 필요한 페이지들과 헤더 컴포넌트를 생성하겠습니다. Next.js의 App Router는 폴더 구조를 기반으로 라우팅을 자동으로 처리하므로, 적절한 위치에 파일을 생성하는 것이 중요합니다.
touch src/app/posts/page.tsx
touch src/app/about/page.tsx
touch src/components/header.tsx
또는 VSCode 등의 에디터에서 직접 폴더와 파일을 생성하셔도 됩니다. 생성된 파일 구조는 다음과 같습니다.
src/
├── app/
│ ├── posts/
│ │ └── page.tsx # /posts 경로
│ ├── about/
│ │ └── page.tsx # /about 경로
│ └── page.tsx # / (홈) 경로
└── components/
└── header.tsx # 공통 헤더 컴포넌트

"use client"
export default function Header() {
return (
<header className="fixed top-0 z-50 h-16 w-screen border-b px-6 backdrop-blur-md">
<div className="container mx-auto flex h-full items-center justify-between">
{/* left logo div */}
<div className="flex h-full flex-1 items-center bg-yellow-500/10"></div>
{/* right button group div */}
<div className="flex h-full flex-1 items-center bg-blue-500/10"></div>
</div>
</header>
)
}import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/header";
...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Header />
<div className="pt-16">{children}</div>
</body>
</html>
);
}
const MainPage = () => {
return <div className="flex min-h-screen justify-center">Main Page</div>
}
export default MainPageconst PostsPage = () => {
return <div className="flex min-h-screen justify-center">PostsPage</div>
}
export default PostsPageconst AboutPage = () => {
return <div className="flex min-h-screen justify-center">AboutPage</div>
}
export default AboutPage"use client"
import Link from "next/link"
export default function Header() {
return (
<header className="fixed top-0 z-50 h-16 w-screen border-b px-6 backdrop-blur-md">
<div className="container mx-auto flex h-full items-center justify-between">
{/* left logo div */}
<div className="flex items-center">
<Link href="/" className="text-2xl font-bold">
My Blog
</Link>
</div>
{/* right button group div */}
<div className="flex h-full flex-1 items-center bg-blue-500/10"></div>
</div>
</header>
)
}"use client"
import Link from "next/link"
export default function Header() {
return (
<header className="fixed top-0 z-50 h-16 w-screen border-b px-6 backdrop-blur-md">
<div className="container mx-auto flex h-full items-center justify-between">
...
{/* right button group div */}
<div className="flex items-center justify-between gap-3 py-6 md:gap-9">
{/* 데스크탑 링크 */}
<nav className="hidden items-center space-x-8 md:flex">
<Link href="/" className="transition-colors hover:text-blue-500">Home</Link>
<Link href="/posts" className="transition-colors hover:text-blue-500">Posts</Link>
<Link href="/about" className="transition-colors hover:text-blue-500">About</Link>
</nav>
{/* 테마 변경 버튼 */}
{/* 모바일 메뉴 */}
</div>
</div>
</header>
)
}"use client";
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggleButton() {
...
}...
export function ThemeToggleButton() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<SunIcon className="size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<MoonIcon className="absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}12. layout에 provider 적용
루트 레이아웃에 ThemeProvider를 적용하여 앱 전체에서 테마 기능을 사용할 수 있도록 설정합니다. 또한 Header 컴포넌트를 추가하여 모든 페이지에서 공통으로 표시되도록 합니다. 중요한 설정 사항.
suppressHydrationWarning: next-themes가 클라이언트에서 테마를 적용할 때 발생할 수 있는 hydration 경고를 방지
lang="ko": 한국어 블로그임을 명시 (SEO 및 접근성 향상)
ThemeProvider 설정 옵션
attribute="class": HTML 요소에 클래스로 테마 적용 (Tailwind CSS와 호환)defaultTheme="system": 시스템 설정을 기본값으로 사용enableSystem: 시스템의 다크모드 설정 자동 감지disable Transition OnChange: 테마 변경 시 깜빡임 방지
...
import { ThemeProvider } from "@/providers/theme-provider";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Header />
<div className="pt-16">{children}</div>
</ThemeProvider>
</body>
</html>
);
}
"use client";
import { ThemeToggleButton } from "@/components/theme-toggle-button";
import Link from "next/link";
export default function Header() {
return (
<header className="fixed top-0 z-50 h-16 w-screen border-b px-6 backdrop-blur-md">
...
{/* 테마 변경 버튼 */}
<ThemeToggleButton />
{/* 모바일 메뉴 */}
</div>
</div>
</header>
);
}

...
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { MenuIcon } from "lucide-react";
export default function Header() {
return (
<header className="fixed top-0 z-50 h-16 w-screen border-b px-6 backdrop-blur-md">
...
{/* 모바일 메뉴 */}
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<SheetTrigger asChild>
<button className="md:hidden p-2" aria-label="Toggle menu"><MenuIcon className="size-6" /></button>
</SheetTrigger>
<SheetContent className="p-6 [&>button]:size-6 [&>button>svg]:size-6">
<SheetHeader>
<SheetTitle className="sr-only">모바일 내비게이션 메뉴</SheetTitle>
<SheetDescription className="sr-only">이 다이얼로그는 모바일 환경에서 사용할 수 있는 내비게이션 메뉴입니다. 아래 링크를 선택하여 사이트의 주요 페이지로 이동할 수 있습니다.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col space-y-4 py-4">
<Link href="/" className="transition-colors hover:text-blue-500">Home</Link>
<Link href="/posts" className="transition-colors hover:text-blue-500">Posts</Link>
<Link href="/about" className="transition-colors hover:text-blue-500">About</Link>
</nav>
</SheetContent>
</Sheet>
...
</header>
);
}
1. Next.js 프로젝트 생성
먼저 터미널을 열고 프로젝트를 생성할 디렉토리로 이동합니다. 그 다음 아래 명령어를 실행하여 Next.js 프로젝트를 생성합니다.
npx create-next-app@latest
프로젝트 생성 과정에서 여러 옵션을 선택하게 됩니다. 이 튜토리얼에서는 다음과 같이 설정하겠습니다.
프로젝트 이름: blog-nextjs-blog (원하는 이름으로 변경 가능)TypeScript: Yes (타입 안정성을 위해 권장)ESLint: Yes (코드 품질 관리)Tailwind CSS: Yes (빠른 스타일링)src/ 디렉토리: Yes (코드 구조화)App Router: Yes (Next.js 13+ 권장 방식)Turbopack: Yes (빠른 개발 서버)Import alias: No (기본값 사용)
npm run dev 명령어를 통해 dev server를 실행합니다.
2. UI 컴포넌트 추가 - shadcn/ui
이번 섹션에 필요한 UI 컴포넌트를 먼저 추가하겠습니다.
shadcn-ui는 다양한 UI 컴포넌트를 빠르게 적용할 수 있도록 도와주는 툴킷입니다.
Next.js 프로젝트에 최적화되어 있으며, 커스터마이징도 유연하게 할 수 있습니다.
아래 명령어 실행하여 button, dropdown-menu, 그리고 sheet 컴포넌트를 추가합니다.
npx shadcn@latest add
명령어를 입력하면 프로젝트 설정에 따라 몇 가지 질문을 받게 되며, 기본값으로 진행해도 괜찮습니다. 설정이 완료되면 사용할 컴포넌트를 선택할 수 있습니다.
3. 페이지 구조 및 헤더 컴포넌트 생성
블로그의 기본 구조를 만들기 위해 필요한 페이지들과 헤더 컴포넌트를 생성하겠습니다. Next.js의 App Router는 폴더 구조를 기반으로 라우팅을 자동으로 처리하므로, 적절한 위치에 파일을 생성하는 것이 중요합니다.
touch src/app/posts/page.tsx
touch src/app/about/page.tsx
touch src/components/header.tsx
또는 VSCode 등의 에디터에서 직접 폴더와 파일을 생성하셔도 됩니다. 생성된 파일 구조는 다음과 같습니다.
src/
├── app/
│ ├── posts/
│ │ └── page.tsx # /posts 경로
│ ├── about/
│ │ └── page.tsx # /about 경로
│ └── page.tsx # / (홈) 경로
└── components/
└── header.tsx # 공통 헤더 컴포넌트
12. layout에 provider 적용
루트 레이아웃에 ThemeProvider를 적용하여 앱 전체에서 테마 기능을 사용할 수 있도록 설정합니다. 또한 Header 컴포넌트를 추가하여 모든 페이지에서 공통으로 표시되도록 합니다. 중요한 설정 사항.
suppressHydrationWarning: next-themes가 클라이언트에서 테마를 적용할 때 발생할 수 있는 hydration 경고를 방지
lang="ko": 한국어 블로그임을 명시 (SEO 및 접근성 향상)
ThemeProvider 설정 옵션
attribute="class": HTML 요소에 클래스로 테마 적용 (Tailwind CSS와 호환)defaultTheme="system": 시스템 설정을 기본값으로 사용enableSystem: 시스템의 다크모드 설정 자동 감지disable Transition OnChange: 테마 변경 시 깜빡임 방지
➜ npx create-next-app@latest
Need to install the following packages:
create-next-app@15.4.6
Ok to proceed? (y)
✔ What is your project named? … blog-nextjs-blog
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias [`@/*` by default?] … No / Yes
Creating a new Next.js app in /Users/mech2cs/work/mech2cs/blog/blog-nextjs-blog.포스트 본문 페이지 구현
이제 블로그의 핵심 기능인 포스트 본문 페이지를 구현해보겠습니다. Next.js의 동적 라우팅을 활용하여 각 포스트를 개별 페이지로 표시하고, 마크다운 파일을 HTML로 렌더링하는 방법을 알아봅니다.
이 섹션에서 다룰 내용
- 동적 라우팅 설정 (
[slug]파라미터) - 마크다운 파일 읽기 및 파싱
- 메타데이터 처리 (제목, 날짜, 태그 등)
- 마크다운을 HTML로 변환 및 스타일 적용
git clone git@github.com:sijunnoh/blog-nextjs-blog.git
git checkout section02
1. 테스트용 MDX 파일 준비하기
포스트 페이지를 테스트하기 위해 샘플 MDX 파일이 필요합니다. MDX는 마크다운에 JSX를 결합한 포맷으로, 메타데이터와 콘텐츠를 함께 관리할 수 있습니다.
src/data/posts 디렉토리를 생성한 후 mdx 파일을 추가합니다.
TIP: 직접 작성해도 되지만, ChatGPT, Claude 등 AI 툴을 활용하면 더 빠르게 다양한 예시를 생성할 수 있습니다.
---
title: "Next.js 14 App Router 완벽 가이드"
date: "2024-12-15"
description: "Next.js 14의 App Router를 활용한 모던 웹 애플리케이션 개발 방법을 상세히 알아봅니다."
tags: ["Next.js", "React", "TypeScript", "Web Development"]
category: "Frontend"
thumbnail: "/images/nextjs-14-thumbnail.jpg"
readingTime: "15분"
draft: false
---
## 1. App Router란?
App Router는 Next.js 13에서 도입된 새로운 라우팅 시스템입니다. 기존의 Pages Router와 비교하여 다음과 같은 장점이 있습니다:
- **서버 컴포넌트 기본 지원**: 클라이언트 번들 크기 감소
- **중첩 레이아웃**: 효율적인 레이아웃 관리
- **스트리밍과 Suspense**: 향상된 로딩 경험
- **병렬 라우트**: 동시에 여러 페이지 렌더링
...
import { promises as fs } from "fs";
import { evaluate, EvaluateOptions } from "next-mdx-remote-client/rsc";
import path from "path";
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params;
const source = await fs.readFile(path.join(process.cwd(), "src/data/posts", `${slug}.mdx`), "utf-8");
const options: EvaluateOptions = {
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
parseFrontmatter: true,
};
const { content } = await evaluate({
source,
options,
});
return (
<main className="flex min-h-full w-full">
<article className="container mx-auto p-6 border-x">{content}</article>
</main>
);
};
export default PostPage;

4. 포스트 본문 기본 스타일 주입
Tailwind CSS는 기본 HTML 스타일을 초기화하기 때문에 마크다운으로 작성된 콘텐츠가 스타일 없이 표시됩니다. 이를 해결하기 위해 @tailwindcss/typography 플러그인을 사용합니다.
npm install @tailwindcss/typographyglobals.css 파일에 @tailwindcss/typography를 추가하고, 포스트 본문 컴포넌트에 prose 클래스를 적용합니다.
prose 클래스의 주요 기능
- 제목, 문단, 리스트 등에 적절한 여백과 크기 자동 적용
- 코드 블록과 인라인 코드 스타일링
- 링크, 인용구, 테이블 등 마크다운 요소 스타일
- 다크모드 자동 대응 (
dark:prose-invert)
...
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
...
return (
<main className="flex min-h-full w-full">
<article className="container mx-auto p-6 border-x">
<div className="prose dark:prose-invert max-w-none overflow-x-auto">{content}</div>
</article>
</main>
);
};
export default PostPage;@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
...
...
import rehypePrettyCode from "rehype-pretty-code";
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
...
const options: EvaluateOptions = {
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [rehypePrettyCode],
},
parseFrontmatter: true,
};
...
};
export default PostPage;
code [data-highlighted-line] {
background: rgba(255, 255, 255, 0.1);
}

"use client" import { DetailedHTMLProps, HTMLAttributes, useRef, useState } from "react" import { CheckIcon, ClipboardIcon } from "lucide-react/" import { Button } from "@/components/ui/button" export default function Pre({ children, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) { const [isCopied, setIsCopied] = useState(false) const preRef = useRef<HTMLPreElement>(null) const handleClickCopy = async () => { const code = preRef.current?.textContent if (!code) return if (navigator.clipboard) { await navigator.clipboard.writeText(code) } else { const textArea = document.createElement("textarea") textArea.value = code document.body.appendChild(textArea) textArea.select() document.execCommand("copy") document.body.removeChild(textArea) } setIsCopied(true) setTimeout(() => { setIsCopied(false) }, 3000) } return ( <div className="group relative"> <pre ref={preRef} {...props} style={{ ...props.style, paddingRight: "3.5rem", }} className="overflow-x-auto" > {children} </pre> <Button size="icon" disabled={isCopied} onClick={handleClickCopy} className="absolute top-2 right-2 h-8 w-8 border border-neutral-700 bg-neutral-900 opacity-60 transition-all hover:bg-neutral-800 hover:opacity-100" variant="ghost" > {isCopied ? ( <CheckIcon className="h-4 w-4 text-green-400" /> ) : ( <ClipboardIcon className="h-4 w-4" /> )} </Button> </div> ) }
"use client"
import { DetailedHTMLProps, HTMLAttributes, useRef, useState } from "react"
import { CheckIcon, ClipboardIcon } from "lucide-react/"
import { Button } from "@/components/ui/button"
export default function Pre({
children,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) {
...
return ...
}
import Pre from "@/features/posts/components/pre";
import type { MDXComponents } from "next-mdx-remote-client/rsc";
export const mdxComponents: MDXComponents = {
pre: (props) => <Pre {...props} />,
};
import { mdxComponents } from "@/features/posts/components/mdx-components";
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
...
const { content } = await evaluate({
source,
options,
components: mdxComponents,
});
return (
<main className="flex min-h-full w-full">
<article className="container mx-auto p-6 border-x">
<div className="prose dark:prose-invert max-w-none overflow-x-auto">{content}</div>
</article>
</main>
);
};
export default PostPage;
import { PostFrontmatter } from '../types/post' interface PostHeaderProps { frontmatter: PostFrontmatter } export function PostHeader({ frontmatter }: PostHeaderProps) { return ( <header className="mb-8"> <div className="space-y-4"> <div className="flex flex-wrap gap-2"> {frontmatter.tags.map((tag) => ( <span key={tag} className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-md dark:bg-gray-800 dark:text-gray-200" > {tag} </span> ))} </div> <h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100"> {frontmatter.title} </h1> <p className="text-lg text-gray-600 dark:text-gray-400"> {frontmatter.description} </p> <div className="flex flex-col sm:flex-row sm:items-center gap-4 text-sm text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-4"> <time dateTime={frontmatter.date}> {new Date(frontmatter.date).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })} </time> {frontmatter.updated && ( <span> · 수정됨: {new Date(frontmatter.updated).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })} </span> )} </div> <div className="flex items-center gap-2"> <span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-md dark:bg-blue-900 dark:text-blue-200"> {frontmatter.category} </span> </div> </div> </div> </header> ) }
import { PostFrontmatter } from '../types/post'
interface PostHeaderProps {
frontmatter: PostFrontmatter
}
export function PostHeader({ frontmatter }: PostHeaderProps) {
return (
<header className="mb-8">
...
</header>
)
}
export type PostFrontmatter = {
title: string
description: string
date: string
updated?: string
slug: string
thumbnail?: string
thumbnailAlt?: string
tags: string[]
category: string
draft: boolean
tutorialData?: string
}
...
import { PostFrontmatter } from "@/features/posts/types/post";
import { PostHeader } from "@/features/posts/components/post-header";
...
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
...
const { content, frontmatter } = await evaluate<PostFrontmatter>({
source,
options,
components: mdxComponents,
});
return (
<main className="flex min-h-full w-full">
<article className="container mx-auto flex px-3">
<div className="prose dark:prose-invert max-w-none overflow-x-auto flex-1 border-x p-6">
<PostHeader frontmatter={frontmatter} />
{content}
</div>
</article>
</main>
);
};
export default PostPage;


...
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import remarkFlexibleToc, { type TocItem } from "remark-flexible-toc";
type Scope = {
toc?: TocItem[];
};
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
...
const options: EvaluateOptions = {
mdxOptions: {
remarkPlugins: [remarkFlexibleToc],
rehypePlugins: [
rehypePrettyCode,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "wrap",
properties: {
className: ["heading-link"],
ariaLabel: "Link to this heading",
},
},
],
...
...
.heading-link {
text-decoration: none;
position: relative;
transition: color 0.2s ease;
}
.heading-link:hover {
color: oklch(0.696 0.17 162.48);
}
.heading-link::before {
content: "#";
position: absolute;
left: -1.5rem;
opacity: 0;
transition: opacity 0.2s ease;
color: oklch(0.556 0 0);
}
.dark .heading-link::before {
color: oklch(0.708 0 0);
}
.heading-link:hover::before {
opacity: 0.5;
}

"use client"; import { useEffect, useState } from "react"; import { TocItem } from "remark-flexible-toc"; interface TableOfContentsProps { toc: TocItem[]; } export function TableOfContents({ toc }: TableOfContentsProps) { const [activeId, setActiveId] = useState<string>(""); useEffect(() => { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { setActiveId(entry.target.id); } }); }, { rootMargin: "0px 0px -80% 0px", threshold: 0.1, } ); // toc 항목들의 ID를 기반으로 heading 요소들을 관찰 const headingElements = toc .map((item) => { const id = item.href.substring(1); // # 제거 return document.getElementById(id); }) .filter((el): el is HTMLElement => el !== null); headingElements.forEach((element) => { observer.observe(element); }); return () => { headingElements.forEach((element) => { observer.unobserve(element); }); }; }, [toc]); if (!toc || toc.length === 0) { return null; } const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => { e.preventDefault(); const targetId = href.substring(1); // # 제거 const element = document.getElementById(targetId); if (element) { element.scrollIntoView({ behavior: "smooth", block: "start" }); } }; return ( <nav className="sticky top-24 pr-4"> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3"> 목차 </h3> <ul className="space-y-1 text-sm"> {toc.map((item, index) => { const targetId = item.href.substring(1); // # 제거 const isActive = activeId === targetId; return ( <li key={index} style={{ paddingLeft: `${(item.depth - 1) * 0.75}rem`, }} > <a href={item.href} onClick={(e) => handleClick(e, item.href)} className={` block py-1.5 px-2 transition-colors duration-200 ${ isActive ? "text-blue-700 dark:text-blue-300 font-medium" : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200" } `} > {item.value} </a> </li> ); })} </ul> </nav> ); }
...
"use client";
import { useEffect, useState } from "react";
import { TocItem } from "remark-flexible-toc";
interface TableOfContentsProps {
toc: TocItem[];
}
export function TableOfContents({ toc }: TableOfContentsProps) {
...
return (
<nav className="sticky top-24 pr-4">
...
</nav>
);
}
22. 포스트 본문에 TOC 추가
포스트 페이지에 TableOfContents 컴포넌트를 추가하여 사이드바에 목차를 표시합니다. 목차는 데스크톱(lg 이상)에서만 표시되며, 스크롤해도 고정된 위치에 유지됩니다.
주요 변경사항
- TableOfContents 컴포넌트 import
- vfileDataIntoScope: "toc" 옵션으로 목차 데이터를 scope에 포함
- 우측 사이드바(aside) 영역 추가
- 반응형 디자인 (lg 브레이크포인트에서만 표시)
레이아웃 구조
- 메인 콘텐츠: flex-1로 가능한 공간 차지
- 사이드바: 고정 너비(w-56) 및 위치(fixed)
- 목차: 헤더 아래(top-16)에 고정
...
import { TableOfContents } from "@/features/posts/components/table-of-contents";
const PostPage = async ({ params }: { params: Promise<{ slug: string }> }) => {
...
const options: EvaluateOptions = {
...
parseFrontmatter: true,
vfileDataIntoScope: "toc",
};
const { content, frontmatter, scope } = await evaluate<PostFrontmatter, {toc?: TocItem[]}>({
source,
options,
components: mdxComponents,
});
return (
<main className="flex min-h-full w-full">
<article className="container mx-auto flex px-3">
...
<aside className="z-40 hidden w-56 flex-shrink-0 lg:block">
<div className="fixed top-16 w-56 h-full overflow-auto p-3">{scope?.toc && <TableOfContents toc={scope.toc} />}</div>
</aside>
</article>
</main>
);
};
export default PostPage;
1. 테스트용 MDX 파일 준비하기
포스트 페이지를 테스트하기 위해 샘플 MDX 파일이 필요합니다. MDX는 마크다운에 JSX를 결합한 포맷으로, 메타데이터와 콘텐츠를 함께 관리할 수 있습니다.
src/data/posts 디렉토리를 생성한 후 mdx 파일을 추가합니다.
TIP: 직접 작성해도 되지만, ChatGPT, Claude 등 AI 툴을 활용하면 더 빠르게 다양한 예시를 생성할 수 있습니다.
4. 포스트 본문 기본 스타일 주입
Tailwind CSS는 기본 HTML 스타일을 초기화하기 때문에 마크다운으로 작성된 콘텐츠가 스타일 없이 표시됩니다. 이를 해결하기 위해 @tailwindcss/typography 플러그인을 사용합니다.
npm install @tailwindcss/typographyglobals.css 파일에 @tailwindcss/typography를 추가하고, 포스트 본문 컴포넌트에 prose 클래스를 적용합니다.
prose 클래스의 주요 기능
- 제목, 문단, 리스트 등에 적절한 여백과 크기 자동 적용
- 코드 블록과 인라인 코드 스타일링
- 링크, 인용구, 테이블 등 마크다운 요소 스타일
- 다크모드 자동 대응 (
dark:prose-invert)
22. 포스트 본문에 TOC 추가
포스트 페이지에 TableOfContents 컴포넌트를 추가하여 사이드바에 목차를 표시합니다. 목차는 데스크톱(lg 이상)에서만 표시되며, 스크롤해도 고정된 위치에 유지됩니다.
주요 변경사항
- TableOfContents 컴포넌트 import
- vfileDataIntoScope: "toc" 옵션으로 목차 데이터를 scope에 포함
- 우측 사이드바(aside) 영역 추가
- 반응형 디자인 (lg 브레이크포인트에서만 표시)
레이아웃 구조
- 메인 콘텐츠: flex-1로 가능한 공간 차지
- 사이드바: 고정 너비(w-56) 및 위치(fixed)
- 목차: 헤더 아래(top-16)에 고정
---
title: "Next.js 14 App Router 완벽 가이드"
date: "2024-12-15"
description: "Next.js 14의 App Router를 활용한 모던 웹 애플리케이션 개발 방법을 상세히 알아봅니다."
tags: ["Next.js", "React", "TypeScript", "Web Development"]
category: "Frontend"
thumbnail: "/images/nextjs-14-thumbnail.jpg"
readingTime: "15분"
draft: false
---
## 1. App Router란?
App Router는 Next.js 13에서 도입된 새로운 라우팅 시스템입니다. 기존의 Pages Router와 비교하여 다음과 같은 장점이 있습니다:
- **서버 컴포넌트 기본 지원**: 클라이언트 번들 크기 감소
- **중첩 레이아웃**: 효율적인 레이아웃 관리
- **스트리밍과 Suspense**: 향상된 로딩 경험
- **병렬 라우트**: 동시에 여러 페이지 렌더링
...포스트 리스트, 메인, About 페이지 구현
블로그의 핵심 페이지들을 구현해보겠습니다. 메인 페이지는 방문자를 환영하고 최신 콘텐츠를 보여주며, 포스트 목록 페이지는 모든 글을 한눈에 볼 수 있게 하고, About 페이지는 블로그와 운영자를 소개합니다.
git clone git@github.com:sijunnoh/blog-nextjs-blog.git
git checkout section03
1. Card 컴포넌트 생성
포스트를 카드 형태로 표시하기 위해 shadcn/ui의 Card 컴포넌트를 설치합니다. 이 컴포넌트는 제목, 설명, 이미지 등을 깔끔하게 표시할 수 있는 컨테이너를 제공합니다.
npx shadcn@latest add card이 컴포넌트는 src/components/ui/card.tsx에 자동으로 생성되며, 포스트 카드를 만들 때 활용할 예정입니다.
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
<Card>
<CardHeader>
<CardTitle>포스트 제목</CardTitle>
<CardDescription>포스트 설명</CardDescription>
</CardHeader>
<CardContent>
콘텐츠 내용
</CardContent>
</Card>
import { promises as fs } from "fs";
import path from "path";
const PostsPage = async () => {
const postFileNames = await fs.readdir(path.join(process.cwd(), "src/data/posts"));
const posts = await Promise.all(
postFileNames
.filter((filename) => filename.endsWith(".mdx"))
.map(async (filename, index) => {
const content = await fs.readFile(path.join(process.cwd(), "src/data/posts", filename), "utf-8");
const { frontmatter } = await evaluate<PostFrontmatter>({
source: content,
options: {
parseFrontmatter: true,
},
});
return {
id: index,
path: "/posts/" + filename.replace(".mdx", ""),
...frontmatter,
};
}),
);
...
};
export default PostsPage;
import Link from "next/link";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
const PostsPage = async () => {
...
return (
<div className="container mx-auto py-8 px-3">
<h1 className="text-3xl font-bold mb-8">블로그 포스트</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link key={post.id} href={post.path}>
<Card className="hover:border-amber-500 h-full flex justify-between flex-col">
<CardHeader>
<CardTitle>{post.title}</CardTitle>
<CardDescription>{post.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-between text-sm text-muted-foreground">
<span>{post.category}</span>
<span>{post.date}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
};
export default PostsPage;import Link from "next/link";
import { Button } from "@/components/ui/button";
const MainPage = () => {
return (
<div>
{/* 히어로 섹션 */}
<section className="container mx-auto px-4 py-20 text-center">
<h1 className="text-5xl font-bold mb-4">개발 블로그</h1>
<p className="text-xl text-muted-foreground mb-8">프로그래밍과 웹 개발에 대한 이야기를 나눕니다</p>
<Button asChild>
<Link href="/posts" className="inline-block bg-primary text-primary-foreground px-6 py-3 rounded-lg hover:opacity-90 transition">
포스트 보러가기
</Link>
</Button>
</section>
{/* 소개 섹션 */}
<section className="container mx-auto px-4 py-12">
<div className="bg-secondary rounded-lg p-8 text-center">
<h2 className="text-2xl font-bold mb-4">블로그 소개</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">이 블로그는 웹 개발, 프로그래밍, 그리고 최신 기술 트렌드에 대한 지식과 경험을 공유하는 공간입니다. 함께 성장하는 개발자 커뮤니티를 만들어가고자 합니다.</p>
</div>
</section>
</div>
);
};
export default MainPage;import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; const AboutPage = () => { return ( <div className="container mx-auto px-4 py-12 max-w-4xl"> <h1 className="text-4xl font-bold mb-8 text-center">About</h1> <Card className="mb-8"> <CardHeader> <CardTitle>블로그 소개</CardTitle> </CardHeader> <CardContent> <p className="text-muted-foreground">이 블로그는 Next.js 14와 TypeScript를 활용하여 만든 개인 기술 블로그입니다. 웹 개발과 프로그래밍에 대한 경험과 지식을 공유하며, 함께 성장하는 개발자 커뮤니티를 만들어가고자 합니다.</p> </CardContent> </Card> <Card className="mb-8"> <CardHeader> <CardTitle>기술 스택</CardTitle> </CardHeader> <CardContent> <div className="flex flex-wrap gap-2"> <span className="px-3 py-1 bg-secondary rounded-md">Next.js 14</span> <span className="px-3 py-1 bg-secondary rounded-md">TypeScript</span> <span className="px-3 py-1 bg-secondary rounded-md">Tailwind CSS</span> <span className="px-3 py-1 bg-secondary rounded-md">MDX</span> <span className="px-3 py-1 bg-secondary rounded-md">React</span> </div> </CardContent> </Card> <Card> <CardHeader> <CardTitle>연락처</CardTitle> </CardHeader> <CardContent> <p className="text-muted-foreground mb-2">문의사항이나 제안사항이 있으시면 언제든 연락 주세요.</p> <div className="space-y-1"> <p>📧 Email: example@email.com</p> <p>💻 GitHub: github.com/username</p> <p>🔗 LinkedIn: linkedin.com/in/username</p> </div> </CardContent> </Card> </div> ); }; export default AboutPage;
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const AboutPage = () => {
return (
<div className="container mx-auto px-4 py-12 max-w-4xl">
<h1 className="text-4xl font-bold mb-8 text-center">About</h1>
{/* 블로그 소개 */}
<Card className="mb-8">
...
</Card>
{/* 기술스택 */}
<Card className="mb-8">
...
</Card>
{/* 연락처 */}
<Card>
...
</Card>
</div>
);
};
export default AboutPage;1. Card 컴포넌트 생성
포스트를 카드 형태로 표시하기 위해 shadcn/ui의 Card 컴포넌트를 설치합니다. 이 컴포넌트는 제목, 설명, 이미지 등을 깔끔하게 표시할 수 있는 컨테이너를 제공합니다.
npx shadcn@latest add card이 컴포넌트는 src/components/ui/card.tsx에 자동으로 생성되며, 포스트 카드를 만들 때 활용할 예정입니다.
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
<Card>
<CardHeader>
<CardTitle>포스트 제목</CardTitle>
<CardDescription>포스트 설명</CardDescription>
</CardHeader>
<CardContent>
콘텐츠 내용
</CardContent>
</Card>마치며
이번 튜토리얼을 통해 Next.js 블로그를 처음부터 끝까지 완성해 보았습니다.
구현한 기능들
- Next.js 14 App Router 기반 프로젝트 초기 설정 반응형 헤더 : 다크모드 토글 & 모바일 메뉴 지원 MDX 기반 포스트 시스템 : 코드 하이라이팅 적용 목차(TOC) 자동 생성 및 스크롤 위치 추적 포스트 목록 페이지 : 카드형 레이아웃 메인 페이지 & About 페이지 구현
이제 기본적인 블로그의 뼈대는 갖추었습니다. 앞으로는 버그를 수정하고, 세부 UI/UX를 개선하며, 배포 환경을 구성해보면 한층 완성도 높은 블로그를 만들 수 있습니다. 배포 후에는 SEO 최적화, 댓글 기능, 검색 기능 등 확장 기능도 시도해 보길 추천드립니다.
참조자료
- Next.js 공식 문서 - App Router, 라우팅, 최적화
- Tailwind CSS - 유틸리티 클래스 레퍼런스
- shadcn/ui - 컴포넌트 라이브러리
- next-mdx-remote - MDX 렌더링
- rehype-pretty-code - 코드 하이라이팅
- next-themes - 다크모드 구현
- GitHub 저장소 - 완성된 코드