Next.js로 마크다운 블로그 만들기
이번에는 Next.js 로 마크다운 블로그 만들기 과제이다.
1. 마크다운이란?
- 일반 텍스트 기반의 경량 마크업 언어로, 문서의 구조와 서식을 간단하게 표현하는 데 사용된다. 주로 웹 기반 문서 작성, README 파일 작성, 블로그 게시물 작성 등에 널리 사용되는 형식이다. 마크다운은 간편하고 가독성이 좋으며, HTML로 변환이 용이하여 다양한 플랫폼에서 쉽게 사용될 수 있다.
- github의 README.md 파일을 생각하면 된다.
2. 과제와 비슷한 예시
과제와 비슷한 예시자료를 Next.js 에서 찾을 수 있었다. 바로 blog-stater!! 아래는 주소다.
https://next-blog-starter.vercel.app/
오늘도 github로 열어서 까보도록 하자.
3. 진행 순서
1) 일단 Next.js 를 설치했다. 타입스크립트 기반으로 작성해야 해서 아래와 같이 설치했다.
yarn create next-app --typescript
2) 위와 같이 입력하면 Project 명 입력하고, 기본 세팅을 할 수 있다.
3) 이제 blog-starter와 유사하게 폴더구조를 만들었다. 이후 파일들을 만들었다. 처음엔 pages라는 폴더도 없고 덩그러니 app 폴더만 있어서 당황했다. 무작정 app 폴더 밑에 index.tsx, _app.tsx 파일을 만들어서 시작했더니 오류가 났다.
공식문서를 통해 pages 폴더 밑에 있어야 된다고 한다.

4) 맨 처음 시작으로 _app.tsx 파일부터 작성하였다. 그 후 index.tsx 파일을 작성하였다.
_app.tsx는 단순히 Component와 Props를 받아 렌더링해주는 역할이었다. index.tsx를 까보니 뭐가 많네?
살펴보니까 components 폴더엔 페이지 레이아웃과 관련된 내용들이 담겨있었고, lib 폴더엔 함수가 담겨있고, interfaces엔 type이 설정된 파일들이 존재했다.
그리고 하단에 과제에서 나온 getStaticProps 라는 함수명이 존재했다. getStaticProps는 정적 페이지를 생성할 때 필요한 데이터를 생성해주는 함수라고 한다. 자세히 알아보니,
Next.js에서 'getStaticProps' 함수는 정적 생성(Static Generation)을 위한 데이터를 미리 가져오는 역할을 한다.
정적 생성은 서버 사이드 렌더링(SSR)이나 클라이언트 측에서 데이터를 가져오는 것보다 빠르게 사전 렌더링된 페이지를 제공하는 방식이다. 'getStaticProps'를 사용하여 사전에 필요한 데이터를 가져와 빌드 시점에 페이지에 주입할 수 있다.
import Container from '../components/container'
import MoreStories from '../components/more-stories'
import HeroPost from '../components/hero-post'
import Intro from '../components/intro'
import Layout from '../components/layout'
import { getAllPosts } from '../lib/api'
import Head from 'next/head'
import { CMS_NAME } from '../lib/constants'
import Post from '../interfaces/post'
type Props = {
allPosts: Post[]
}
export default function Index({ allPosts }: Props) {
const heroPost = allPosts[0]
const morePosts = allPosts.slice(1)
return (
<>
<Layout>
<Head>
<title>{`Next.js Blog Example with ${CMS_NAME}`}</title>
</Head>
<Container>
<Intro />
{heroPost && (
<HeroPost
title={heroPost.title}
coverImage={heroPost.coverImage}
date={heroPost.date}
author={heroPost.author}
slug={heroPost.slug}
excerpt={heroPost.excerpt}
/>
)}
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</Container>
</Layout>
</>
)
}
export const getStaticProps = async () => {
const allPosts = getAllPosts([
'title',
'date',
'slug',
'author',
'coverImage',
'excerpt',
])
return {
props: { allPosts },
}
}
5) index.tsx에서 필요하다고 생각하는 부분만 작성하였다. heroPost부분과 morePosts를 따로 구분하지 않아도 된다고 생각해서 간단하게 작성했다. 그리고 index.tsx에서는 목록만을 보여줄 것이기 때문에 리스트 태그를 사용했다.
import { getAllPosts } from "@/lib/api"
import Head from "next/head"
import Link from "next/link"
import Layout from "@/components/layout"
import Container from "@/components/container"
import type PostType from "@/interfaces/post"
type Props = {
allPosts : PostType[],
}
export default function Index({allPosts} : Props){
return (
<>
<Layout>
<Head>
<title>Blog Starter</title>
<meta name="description" content="Blog Starter" />
</Head>
<Container>
<ul>
{allPosts.map((post) => (
<li key={post.slug}>
<Link as={`/posts/${post.slug}`}
href="/posts/[slug]">
{post.title}
</Link>
</li>
))}
</ul>
</Container>
</Layout>
</>
)
}
export const getStaticProps = async () => {
const allPosts = getAllPosts([
'slug',
'title',
'date',
'description'
])
return {
props : {allPosts}
}
}
6) type 설정을 위해 interfaces 폴더에 post 파일 만들고 타입설정을 해주었다.
이후 lib 폴더에 api.ts 파일을 만들고 마크다운 문법을 읽을 수 있도록 작성해주었다.
import fs from 'fs';
import {join} from 'path';
import matter from 'gray-matter';
const postsDirectory = join(process.cwd(), '_posts');
// _posts 폴더에 있는 파일들을 읽어주는 함수.
export function getPostSlugs() {
return fs.readdirSync(postsDirectory);
}
// 마크다운 문법 post들을 가져와서 읽어주는 함수.
export function getPostBySlug(slug : string, fields : string[] = []){
const realSlug = slug.replace(/\.md$/, '');
const fullPath = join(postsDirectory, `${realSlug}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const {data, content} = matter(fileContents);
type Items = {
[key : string]: string
}
const items: Items = {};
fields.forEach((field) => {
if(field === 'slug'){
items[field] = realSlug
}
if(field === 'content'){
items[field] = content
}
if(typeof data[field] !== 'undefined'){
items[field] = data[field]
}
})
return items
}
// 모든 post들을 불러와서 시간 순서대로 정렬해주는 함수.
export function getAllPosts(fields : string[] = []){
const slugs = getPostSlugs();
const posts = slugs
.map((slug) => getPostBySlug(slug, fields))
.sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
return posts;
}
여기서 gray-matter 를 추가해줬다. 이 라이브러리는 마크다운 파일의 메타데이터를 추출하고 메타데이터와 본문 내용을 분리하여 처리까지 해주는 아주 고마운 라이브러리라고 한다.
7) 잊어버렸던 pages 폴더에 _document.tsx 파일도 작성해주었다. 사용자 정의 문서를 작성하기 위해 사용되는 파일이다.
8) 이제 [slug].tsx 파일을 작성할 차례다.
[slug].tsx 파일은 Next.js 에서 동적 라우팅을 구현하기 위해 사용되는 파일이다. 동적 라우팅은 URL 경로에 따라 동적으로 페이지를 생성하는 기능을 제공한다.
동적 라우팅을 사용하여 [slug].tsx 파일을 생성하면, 블로그의 각 포스트에 대한 고유한 경로를 생성할 수 있다.
오우.... 그럼 작성해보자.
import {useRouter} from 'next/router';
import type PostType from "@/interfaces/post"
import { getAllPosts, getPostBySlug } from '@/lib/api';
import markdownToHtml from '@/lib/markdownToHtml';
import Layout from '@/components/layout';
import Container from '@/components/container';
import PostHeader from '@/components/post-header';
import PostBody from '@/components/post-body';
// 타입
type Props = {
post: PostType,
morePosts : PostType[],
}
// post 렌더링 함수
export default function Post({post, morePosts}: Props){
const router = useRouter();
if(!router.isFallback && !post?.slug){
return <>Error!!</>
}
return (
<>
<Layout>
<Container>
{router.isFallback ? (
<>Loading...</>
): (
<article className="mb-32">
<PostHeader
title={post.title}
date={post.date}
/>
<PostBody content={post.content}/>
</article>
)}
</Container>
</Layout>
</>
)
}
// 타입
type Params = {
params: {
slug: string,
}
}
// 정적 생성된 페이지의 데이터를 가져오는 함수.
export async function getStaticProps({ params }: Params){
const post = getPostBySlug(params.slug, [
'slug',
'title',
'date',
'categories',
'description',
'tags',
'content',
])
const content = await markdownToHtml(post.content || '');
return {
props: {
post: {
...post,
content,
}
}
}
}
// 동적 라우팅을 위한 경로 설정 함수.
export async function getStaticPaths() {
const posts = getAllPosts(['slug']);
return {
paths: posts.map((post) => {
return {
params: {
slug: post.slug,
},
}
}),
fallback: false,
}
}
getStaticProps는 정적 생성된 페이지의 데이터를 가져오는 역할이고, getStaticPaths는 동적 라우팅 경로를 설정하여 해당 경로들을 사전 렌더링하는 역할이다.
9) lib 폴더 밑에 markdownToHtml.ts 파일을 작성했다. 마크다운으로 작성된 파일을 html로 변환시켜주는 역할을 한다.
라이브러리로 remark와 remark-html 를 추가했다.
import { remark } from 'remark';
import html from 'remark-html';
export default async function markdownToHtml(markdown: string){
const res = await remark().use(html).process(markdown);
return res.toString();
}
* 만약 .use(html) 에서 Error 가 발생한다면, remark-html를 15.0.1 버전으로 설치해보길 바란다.
github issue에도 없고 아무리 찾아도 안나와서 하나씩 다운그레이드 했었다..........
10) 레이아웃 하나 없이 완성된 페이지!!


4. 정리 및 후기
- 무작정 따라할 땐 이해가 되지 않거나 이게 왜 이렇게 작성되는건 지에 대해 대충 넘어갔던거 같다. 이렇게 한 번 더 파일 보고 정리하니까 좀 낫다. 지금 만든건 하나도 안 이뻐서 글 작성하고 조금씩 다듬을 예정이다. 기능도 더 추가하구....
- 증말 어렵당.
1) 작성한 github 주소 : https://github.com/taehankim-dev/preonboarding_fe_challange02
2) blog-stater 주소 : https://github.com/vercel/next.js/tree/canary/examples/blog-starter
3) next.js 주소 : https://nextjs.org/
그래도 재밌당