Next.js로 블로그 만들기
Next.js로 블로그 만드는 방법에 대한 포스트 ( with monorepo, shadcn/ui )
경고글이 조금 난잡해서 언젠가 다시 정리할게요.
🕹️ 목적
티스토리나 벨로그같은 유명한 블로그 플랫폼을 사용하지 않고 개인 블로그를 만든 이유중 가장 큰 이유는 제 마음대로 커스텀하는 블로그를 만들고 싶었어요.
지금까지 사용해본 블로그는 처음에 벨로그, 두번째로 깃헙 블로그를 사용해봤어요.
벨로그는 프론트나 백엔드에 대해 아는것은 거의 없고 일단 블로그를 사용해보자는 목적에서 처음 선택했던 플랫폼이에요.
어느정도 사용하다가 명색에 개발자인데 플랫폼을 사용하기보다는 개인적으로 운영하는 블로그를 사용해보고 싶어서 선택한게 깃헙 블로그에요.
깃헙 블로그는 템플릿과 형식이 정해진것을 사용했고 개인 블로그가 생겨서 처음에는 만족스럽게 사용했어요.
사용하다보니 이슈가 있었는데 제일 큰것은 블로그 글이 50개가 넘어가니까 개발환경에서 반영되는 속도가 너무 느렸어요.
거의 저장하고 반영되는데 20초정도 걸리는 문제가 제일 컸고, 두번째로는 커스텀을 하고 싶은데 방법을 찾는게 너무 힘들었어요.
그래서 마지막으로 선택한게 next.js
+ mdx
로 만든 현재 블로그에요.
현재 블로그는 next.js
문법으로 커스텀도 가능하고 아직 포스트가 많지 않아서 정확하게는 알 수 없지만 속도 이슈도 없을 것 같아서 계속 사용할 것 같아요.
( 참고로 mdx
란 markdown
+ jsx
로 markdown
내부에서 jsx
를 사용할 수 있는 문법이에요. )
0️⃣ 블로그 포스트 방식
정보
mdx
란markdown
+jsx
로markdown
내부에서jsx
를 사용할 수 있는 문법이에요. 마크다운에 컴포넌트를 사용할 수 있다는게 너무 신기하지 않나요? 🫢
현재 모든 세팅을 해놔서 apps/blog/src/_posts
에 있는 모든 폴더 구조에 맞게 .mdx
파일이 하나의 포스트가 돼요.
예를 들어서 _posts/projects/blog/next-js-mdx.mdx
파일이 있으면 블로그에는 projects/blog/next-js-mdx
경로로 들어가는 포스트가 돼요.
참고로 폴더명에 _
를 붙인 이유는 파일 탐색기 제일 위에 나오게 하고 싶어서 붙인거라 별다른 의미는 없어요.
1️⃣ DB를 사용하지 않은 이유
게시글 자체가 .mdx
파일이기 때문에 굳이 DB
를 사용하지 않아도 될 것 같다고 생각해서 백엔드 없이 구동되는 프로젝트에요.
물론 DB
가 없어서 게시글에 거의 기본적으로 있는 댓글, 좋아요 등의 기능을 구현할수가 없어요.
사실 supabase
를 이용해서 로그인, 댓글, 좋아요 등의 기능을 구현했었는데 처음 의도와 다르게 점점 복잡해지는 것 같아서 다시 빼버렸어요.
그리고 댓글은 DB
를 사용하지 않더라도 utterances
같은 것을 사용하면 되긴하는데 커스텀이 불가능한 라이브러리를 사용하고 싶지 않아서 선택하지 않았어요.
그래도 블로그에 해당 기능들이 없어서 허전한 느낌이 들어서 추후에 기능을 추가하려고 생각만 하고 있어요.
Next.js PPR이 안정적으로 출시되는 시점을 기점으로 생각하고 있어요.
📌 기본 세팅
정보
중요하지 않은 폴더는 생략했어요.
├── apps │ └── blog │ ├── public │ │ ├── fonts │ │ └── images │ ├── src │ ├── @types │ ├── _posts │ │ ├── projects │ │ │ └── blog │ ├── app │ │ ├── (BasicLayout) │ │ │ ├── (home) │ │ │ ├── _components │ │ │ ├── series │ │ │ ├── tags │ │ │ └── timeline │ │ ├── (PostLayout) │ │ │ └── posts │ │ │ └── [...slugs] │ │ ├── _components │ │ │ ├── atoms │ │ │ ├── molecules │ │ │ └── organisms │ │ └── api │ │ ├── metadata │ │ └── thumbnail │ ├── components │ │ ├── layout │ │ ├── mdx │ │ └── providers │ ├── constants │ ├── css │ ├── libs │ ├── stores │ └── types │ ├── next.config.mjs │ └── mdx-components.tsx └── packages ├── constants ├── eslint-config ├── typescript-config └── ui └── src ├── components ├── lib └── styles
0️⃣ monorepo 세팅
굳이 모노레포를 사용하지 않아도 되지만 이후에 추가될 프로젝트도 같이 관리하고 싶어서 사용했어요.
현재는 블로그만 있지만 추후에 포트폴리오, 나만의 코딩 컨벤션 문서 등을 추가할 생각을 갖고 있어요.
0️⃣-0️⃣ shadcn/ui 세팅
블로그 개발을 시작한 시점에는 tailwindcss
가 v3
였고, daisyUI
를 사용했었는데 최근에 v4
로 업데이트 되고, 마이그레이션 하는김에 shadcn
을 사용하는게 더 편할 것 같아서 디자인시스템 라이브러리를 바꿨어요.
공식 문서에서 보일러 플레이트를 제공해줘서 해당 방법으로 기본 세팅을 했어요.
pnpm dlx shadcn@canary init
근데 이 방법으로 진행했을때 몇가지 이슈가 있었어요.
TODO: 1, 2, 3
1️⃣ mdx-components 세팅
mdx-components.tsx
파일은 next.js
에서 약속된 이름이에요.
위치는 최상위에 있어야 하고, 만약 src
를 사용한다면 src
와 같은 위치에 있으면 돼요.
해당 파일의 역할은 .mdx
에서 사용할 컴포넌트를 정의하는 파일이에요.
import type { MDXComponents } from "mdx/types"; import { cn } from "@workspace/ui/lib/utils"; import LinkPreviewCard from "#/components/mdx/LinkPreviewCard"; /** 모든 `.mdx`에 적용 ( `next.js`에서 약속된 이름 ) */ export function useMDXComponents(components: MDXComponents): MDXComponents { return { /** 문장 사이의 간격 지정 */ p: (props) => ( <p {...props} className={cn( props.className, "!mb-3 [blockquote_&]:!mb-0 break-keep tracking-normal leading-relaxed" )} /> ), /** 링크 미리보기 카드 */ LinkPreviewCard, ...components, }; }
1️⃣-0️⃣ HTML 대체 컴포넌트
기본 엘리먼트를 오버라이딩하고 싶으면 해당 태그명에 컴포넌트로 정의해주면 돼요.
저의 경우에는 <p>
를 커스텀하기 위해서 11~19
번 라인처럼 작성했어요.
1️⃣-1️⃣ 커스텀 컴포넌트
기본 HTML
이 아닌 컴포넌트는 반환 객체에 추가해주기만하면 mdx
에서 사용할 수 있어요.
( 혹은 mdx
에서 import
해도 돼요. )
<LinkPreviewCard>
컴포넌트는 링크를 입력해주면 링크 미리보기 카드를 보여주는 컴포넌트에요.
<LinkPreviewCard href="https://nextjs.org/docs/app/api-reference/file-conventions/mdx-components" />
mdx
에서 위처럼 사용하면 아래와 같은 결과를 얻을 수 있어요.
2️⃣ next.config 세팅
import createMDX from "@next/mdx"; import remarkGfm from "remark-gfm"; import rehypePrism from "rehype-prism-plus"; /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ["@workspace/ui"], pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], }; const withMDX = createMDX({ options: { remarkPlugins: [remarkGfm], rehypePlugins: [ /** 코드 블럭 */ rehypePrism, ], }, }); export default withMDX(nextConfig);
next.js
에서 mdx
를 사용하기 위해서는 몇가지 세팅이 필요해요.
2️⃣-0️⃣ 설치
아래 라이브러리는 그냥 필수적으로 설치해야 하는것으로 알고 있어요.
pnpm i @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
2️⃣-1️⃣ remark-gfm
정보
GitHub
에서 사용하는 마크다운 문법과 동일하게 사용할 수 있도록 도와주는 라이브러리에요.
2️⃣-2️⃣ rehype-prism-plus
정보
코드블럭 스타일과 문법 하이라이트를 적용해주는 라이브러리에요.
단, 스타일을 적용하려면 prism-night-owl.css
, rehype-prism-plus.css
를 참고해주세요.
diff-tsx
: 접두사-
,+
인지에 따라 강조 색상 표시showLineNumbers
: 라인 넘버 표시{2-3, 5}
: 각 넘버에 강조 색상 표시
// ```diff-tsx showLineNumbers {2-3, 5} const v1 = "강조1"; const v2 = "강조2"; const v4 = "강조4" - const v5 = "제거" + const v5 = "추가" // ```
3️⃣ 페이지 세팅
정보
3️⃣-0️⃣ 라우팅 세팅
정보
src/app/(PostLayout)/posts/[...slugs].tsx
파일은 블로그 포스트 페이지에요.
[...slugs]
를 사용한 이유는 뎁스가 깊어질 수 있기 때문에 사용했어요.
이유는 정확히 모르겠으나 mdx
를 불러올때는 dynamic()
을 사용해야 에러가 안나더라고요.
그리고 dynamic()
에서 절대경로를 사용하면 빌드 시 에러가 발생해서 상대경로를 사용했어요.
const Page: NextPage<Props> = async ({ params }) => { const { slugs } = await params; const postURL = decodeURIComponent(slugs.join("/")); // Vercel 빌드 시 에러 발생으로 인해 상대경로 사용 const Post = dynamic(() => import(`../../../../_posts/${postURL}.mdx`)); return ( <div className="relative"> <Post /> </div> ); };
3️⃣-1️⃣ 정적 페이지
현재 게시글에서 동적인 부분이 없기 때문에 generateStaticParams()
를 이용해서 빌드 시점에 정적 페이지를 생성해요.
이후에 PPR
이 안정적으로 출시되면 게시글 내부에 동적인 요소를 추가할 생각이에요
export const generateStaticParams = () => { return allPosts.map(({ path }) => ({ slugs: path.split("/").filter((v) => !["", "posts"].includes(v)), })); };
4️⃣ 메타 데이터 세팅
정보
4️⃣-0️⃣ 게시글 정보 읽기
게시글 정보를 읽고 가공할때 많은 라이브러리를 사용해요.
pnpm i glob gray-matter dayjs reading-time
glob
: 폴더 내 모든 파일 경로 읽기gray-matter
:mdx
파일 내 메타데이터 읽기dayjs
: 날짜 포맷팅reading-time
: 소요 시간 계산
/** 게시글 기본 경로 */ const DEFAULT_PATH = "/posts"; /** 모든 게시글의 메타데이터 및 내용 얻는 함수 */ export const getAllPosts = cache((publishedOnly = true): IPostWithETC[] => { /** 모든 게시글들이 저장되어있는 폴더 경로 ( `src/_posts` ) */ const postFolderPath = path.join(process.cwd(), "src", "_posts"); /** 모든 게시글들의 경로들 ( `/Users/openknowl/MyWorkspace/trivia-log/src/_posts/state-management/redux.mdx` ) */ const allPostPaths = sync(`${postFolderPath}/**/*.mdx`); const allPosts = allPostPaths.map((postPath) => { /** 특정 게시글 파일 데이터 */ const postFileData = fs.readFileSync(postPath, { encoding: "utf8" }); // 게시글 메타데이터 얻기 const { data, content } = matter(postFileData); const metadata = data as IPost; /** 게시글 상대 경로 ( `/state-management/redux` ) */ const relativePostPath = postPath .slice(postFolderPath.length) .replace(".mdx", ""); return { content, ...metadata, createdAt: dayjs(metadata.createdAt).format("YYYY-MM-DD"), publishedAt: dayjs(metadata.publishedAt).format("YYYY-MM-DD"), path: DEFAULT_PATH + relativePostPath, thumbnail: metadata.thumbnail || makeThumbnailPath({ title: metadata.title, description: metadata.description, publishedAt: metadata.publishedAt, }), breadcrumbs: relativePostPath.split("/").filter((v) => v !== ""), readingMinutes: Math.ceil(readingTime(content).minutes), wordCount: content.split(/\s+/g).length, }; }); if (publishedOnly) return allPosts.filter(({ draft }) => !draft); return allPosts; });
참고로 저는 메타 데이터를 아래와 같은 형식으로 사용하고 있고, gray-matter
에서 아래의 값을 알아서 key
와 value
로 나눠서 반환해줘요.
--- title: "Next.js로 블로그 만들기" description: "Next.js로 블로그 만들기" tags: ["Next.js", "블로그", "개발", "개인 블로그", "next.js mdx"] icon: "" thumbnail: "" createdAt: 2025-03-09 21:50:00 publishedAt: 2025-03-09 21:50:00 sitemap: lastmod: 2025-03-09 21:50:00 changefreq: weekly priority: 0.5 draft: false ---
4️⃣-1️⃣ sitemap 세팅
sitemap
은 검색엔진에게 어떤 페이지가 있는지 알려주기 위한 파일이에요.
네이버나 구글 검색엔진에 사이트를 등록할때 같이 알려주면 좋아요.
import type { MetadataRoute } from "next"; import { getAllPosts } from "#/libs/post"; import { ROUTES } from "#/constants"; import type { IRoute } from "#/types"; const allPosts = getAllPosts(); /** 재귀적으로 돌아서 `sitemap` 생성 */ const generateSitemap = (routes: IRoute[]): MetadataRoute.Sitemap => { return routes.flatMap(({ path, sitemap }) => [ { url: `${process.env.NEXT_PUBLIC_CLIENT_URL}${path}`, priority: sitemap?.priority, lastModified: sitemap?.lastmod, changeFrequency: sitemap?.changefreq, }, ]); }; const sitemap = (): MetadataRoute.Sitemap => { return [ ...generateSitemap( ROUTES.filter((route): route is Required<IRoute> => !!route.sitemap) ), ...allPosts.map((postMetadata) => ({ url: `${process.env.NEXT_PUBLIC_CLIENT_URL}${postMetadata.path}`, priority: postMetadata.sitemap.priority, lastModified: postMetadata.sitemap.lastmod, changeFrequency: postMetadata.sitemap.changefreq, })), ]; }; export default sitemap;
4️⃣-2️⃣ robots 세팅
robots.txt
는 검색엔진에게 어떤 페이지를 크롤링할지 안할지 알려주는 파일이에요.
import { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: ["/"], }, ], sitemap: "https://blog.story-dict.com/sitemap.xml", host: "https://blog.story-dict.com", }; }
5️⃣ 썸네일 세팅
현재 모든 게시글의 썸네일은 게시글의 메타데이터 기반으로 동적으로 만들어지고 있어요.
방법이 궁금하다면 Next로 동적 썸네일 만들기를 참고해주세요.
📮 레퍼런스
- 과거 벨로그
- 과거 깃헙 블로그
- Next.js - PPR
- Next.js - MDX
- GitHub - remarkjs/remark-gfm
- GitHub - rehype-prism-plus
- Next.js - catch all Segument
- Next.js - Metadata
- Next로 동적 썸네일 만들기