🦾
NextJS로 블로그 만들며 겪은 좌충우돌 탐험기
blog
2025-04-03
사용한 기술
NextJS + TailwindCSS
마크다운 파싱: @next/mdx
처음에는 마크다운 파싱을 remark, remark-html로 했었다. 그러다가 @next/mdx 가 nextJS에서 지원하기도 하고 무엇보다 전역 MDXProvider를 제공하여서, 전역적인 스타일링 하기가 용이하다고 판단했다.
remark, remark-html의 경우에는 tailwind의 preflight 스타일 때문에, 기본 h1, h2, code와 같은 태그들의 스타일이 초기화가 된다. 그렇기에 @base 레이아웃에 오버라이드를 하여서 스타일을 재정의 해줘야 한다. 사실 @next/mdx도 스타일을 MDXProvider에서 재정의 해줘야 하지만. . .(조삼모사) 그래도 이 방식이 좀 더 깔끔하다고 생각했다. 그리고 @next/mdx를 이용함으로서 NextJS에 최적화 된 기능들을 사용할 수 있었기에 @next/mdx를 사용했다. 아래는 내가 했던 삽질에 대한 내용이다.
TailwindCSS Preflight
애 먹었던 부분 중 하나는, 마크다운에 스타일을 적용하는 것이었다. 하.. 정말 이거 때문에 고생을 했다. tailwind의 preflight 스타일 초기화 때문에 모든 태그들의 스타일이 초기화가 이루어져서 markdown파싱이 성공적으로 <pre><code>
. . 와 같이 이루어졌어도 스타일은 그대로였다.
그래서 어떻게 해결을 시도했냐 . . . 맨 처음에는 전역 css 파일에 base 레이어를 확장해서 썼다. 그 이유는 Preflight를 유지하면서 필요한 커스텀 스타일을 덧붙이는 것이 일관성과 예측 가능한 결과를 보장하는 방법이기 때문이다. 그러다가, @next/mdx로 마크다운 파싱하는 방식을 바꾸고 나서는, mdx전역 Provider에 각 태그에 맞춘 커스텀 컴포넌트를 정의하여 매핑해주었다.
BEFORE
1@layer base {2 .main {3 display: flex;4 flex-direction: column;5 overflow: hidden;6 word-wrap: break-word;7 font-size: 1.125rem; /* 기본 본문 크기 = 18px */8 padding: 30px 0;9 box-sizing: border-box;10 line-height: 1.8;11 color: hsl(var(--foreground));12 max-width: 1000px;13 }1415 .main h1 {16 font-size: 2.5rem; /* 약 40px */17 font-weight: 800;18 line-height: 1.2;19 margin: 1rem 0;20 }2122 .main h2 {23 font-size: 2rem;24 font-weight: 700;25 margin: 1rem 0;26 }2728 .main h3 {29 font-size: 1.75rem;30 font-weight: 600;31 margin: 1rem 0;32 }33}
AFTER
1import type { MDXComponents } from "mdx/types";2import React from "react";3import { Pre } from "./components/Pre";4import Header from "./components/Header";5import Footer from "./components/Footer";67const H1 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (8 <h19 className="text-[var(--text)] text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold my-7"10 {...props}11 />12);1314const H2 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (15 <h216 className="text-[var(--text)] text-xl sm:text-2xl md:text-3xl lg:text-4xl font-semibold my-6"17 {...props}18 />19);2021const H3 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (22 <h323 className="text-[var(--text)] text-lg sm:text-xl md:text-2xl lg:text-3xl font-semibold my-5"24 {...props}25 />26);27. . .2829export function useMDXComponents(components: MDXComponents): MDXComponents {30 return {31 h1: H1,32 h2: H2,33 h3: H3,34 . . .35 ...components,36 };37}
개발 블로그의 핵심 - 코드블럭
다음으로 애 먹었던 부분은 바로 코드블럭이다. 코드 블럭에 각 언어에 맞춘 문법 하이라이트 기능이 있어야 하는데, 그게 하나도 없다 보니 너무 허접한 느낌이 들었다. 그래서 prism-react-renderer라이브러리를 도입했다.
1// mdx-components.tsx2export function useMDXComponents(components: MDXComponents): MDXComponents {3 return {4 h1: H1,5 . . . .6 code: InlineCode,7 pre: CodeBlock,8 ...components,9 };10}1112// CodeBlock.tsx13const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {14 if (!code) return null;1516 return (17 <Highlight theme={themes.shadesOfPurple} code={code} language={language}>18 {({ style, tokens, getLineProps, getTokenProps }) => (19 <pre style={style}>20 {tokens.map((line, i) => (21 <div key={i} {...getLineProps({ line })}>22 <span>{i + 1}</span>23 {line.map((token, key) => (24 <span key={key} {...getTokenProps({ token })} />25 ))}26 </div>27 ))}28 </pre>29 )}30 </Highlight>31 );32};
원래는 위와 같이 컴포넌트를 정의하여 사용했다.
그러나 의도한 대로 코드블럭이 렌더링되지 않았다. 바로 props(code, language)가 undefined로 넘겨지고 있었다.
해당 문서에서 mdx의 provider에서는 코드블럭 정보를 children으로 받을 수 있다는 사실을 알아냈다. children은 Reac.Node 타입이다.
1{2 '$$typeof': Symbol(react.transitional.element),3 type: [Function: InlineCode],4 key: null,5 props: {6 className: 'language-javascript',7 children: 'function greet(name) {\r\n' +8 ' return `Hello, ${name}!`;\r\n' +9 '}\r\n' +10 '\r\n' +11 'console.log(greet("World"));\n'12 },13 _owner: {14 name: 'MDXContent',15 env: 'Server',16 key: null,17 owner: null,18 props: { params: [Promise], searchParams: [Promise] }19 },20 _store: {}21}
실제 우리가 작성한 코드는 children.props.children에 문자열 형태로 있다. 그리고 어떤 확장자로 썼는지에 대한 정보는 className으로 넘겨받을 수 있다.
1export default function CodeBlock({2 children, // React 노드 - 코드 데이터 얻기 위함34}: CodeBlockProps): JSX.Element {5. . .6const className = children?.props.className; // 코드가 어떤 포맷으로 쓰였는지 확인하기 위함('language-<포맷>' 형태)
이제 해당 언어에 대한 내용을 prism-react-renderer에서 제공하는 인터페이스인 Highlight에 넘겨주면 된다.
code: children.props.childer, language: children.props.className 이렇게 추출해서 사용하면 된다.
그러나 타입스크립트를 사용하면 다음과 같은 인터페이스를 추가해서 해당 속성에 대해 명시해줘야 한다. 그러지 않으면 unknown' 형식에 'className' 속성이 없습니다.
에러가 생긴다.
1interface CodeElementProps {2 children?: string;3 className?: string;4}
위 내용을 반영한 코드는 아래와 같다.
1const { className: codeClassName } = children.props as CodeElementProps;2const { title, highlight, language } = parseMeta(codeClassName);34const { children: codeString } = children.props as CodeElementProps;5const code = typeof codeString === "string" ? codeString : "";67return (8 <Highlight theme={themes.dracula} code={code} language={language}>9 {({ style, tokens, getLineProps, getTokenProps }) => {10 const slicedTokens = tokens.slice(0, -1);1112 return (13 <pre14 style={{15 ...style,16 borderRadius: "10px",17 padding: "10px 0",18 margin: "20px 0",19 }}20 >21 {slicedTokens.map((line, i) => (22 <div key={i} {...getLineProps({ line })}>23 <span style={{ padding: "0 15px 0 10px", color: "#6a6192" }}>24 {i + 1}25 </span>26 {line.map((token, key) => (27 <span key={key} {...getTokenProps({ token })} />28 ))}29 </div>30 ))}31 </pre>32 );33 }}34 </Highlight>35 );
이번엔 posts가 안보임
/posts
경로로 가면 다음과 같이 내가 작성한 글들이 리스트 형태로 보여야 한다.

배포를 하고 들어가 보니 에러가 나를 반겨줬다.(언제봐도 전혀 반갑지 않은 친구다.)


해당 경로에 존재하는 파일이 없다는 것이었다. . .
왜지? 로컬에선 잘 됐는데, 빌드하여 배포를 하니 해당 문제가 생기는 걸까?
아뿔싸! 현재 mdx 파일들을 찾기 위해 /(contents)아래의 폴더들의 page.mdx를 다음과 같이 가져오고 있었다.
1const posts = await Promise.all(2 slugs.map(async (slug) => {3 const data = await import(`../app/(contents)/${slug}/page.mdx`);4 const metadata = data.meta;5 return { slug, ...metadata } as PostData;6 })7);
그러나 여기서 문제가 되는 부분은 바로 상대경로를 이용하고 있다는 점이다.
배포를 하게 되면 현재 작업 디렉토리(process.cwd())와의 상대 관계가 달라질 수 있어 올바른 디렉토리를 찾지 못하게 된다. 그래서 process.cwd()를 통해 절대경로로 명시를 해줘야 한다.
1// MDX 파일들이 위치한 디렉토리2const postsDir = path.join(process.cwd(), "src", "app", "(contents)");
사소한 오류
remark 플러그인을 적용하고,pnpm run dev 를 통해 dev 서버를 돌렸는데,,, 오류가 발생하여 돌아가질 않았다.
1import remarkGfm from "remark-gfm";2import createMDX from "@next/mdx";34/** @type {import('next').NextConfig} */5const nextConfig = {6 // Allow .mdx extensions for files7 pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],8 // Optionally, add any other Next.js config below9};1011const withMDX = createMDX({12 // Add markdown plugins here, as desired13 options: {14 remarkPlugins: [remarkGfm],15 },16});1718// Combine MDX and Next.js config19export default withMDX(nextConfig);
이게 무슨 에러일까..
1> next dev --turbo23 ▲ Next.js 15.1.7 (Turbopack)4 - Local: http://localhost:30005 - Network: http://192.168.35.53:30006 - Experiments (use with caution):7 · turbo89 ✓ Starting...10[Error: loader /Users/castle_bell/Documents/Github/bellog/node_modules/.pnpm/@next+mdx@15.2.4_@mdx-js+loader@3.1.0_acorn@8.14.1__@mdx-js+react@3.1.0_@types+react@19.0.10_react@19.0.0_/node_modules/@next/mdx/mdx-js-loader.js for match "*.mdx" does not have serializable options. Ensure that options passed are plain JavaScript objects and values.]
이슈를 찾아보니 dev 스크립트에서 아래와 같이 뒤에 —turbo 키워드가 포함되어 있으면 해당 문제가 생기는 듯 헀다.
1 "scripts": {2 "dev": "next dev --turbo",3 "build": "next build",4 "start": "next start",5 "lint": "next lint",6 "prepare": "husky"7 },
그래서 — turbo를 빼줬더니 잘 돌아갔다ㅎㅎ turbo는 NextJS의 기본 번들러인 Webpack 대신, 더 진보된 turbopack을 사용하도록 하는 키워드다. 그러나 아직 불안정한 부분이 있는 것 같다.