Nextra로 무료 블로그 만들기
Nextra로 무료 블로그 만들기
저는 Jekyll과 Obsidian Pages를 거쳐온 난민입니다.
무엇을 써도 썩 마음엔 들지 않더라고요. 그렇다고 밑바닥부터 직접 만드는 것도 썩 내키지 않았습니다. 굳이 바퀴를 다시 발명하고 싶지는 않았거든요.
사실 Nextra는 꽤 오래전부터 눈여겨보고 있었는데요, 타입스크립트 예제가 부족하고 타입 정의 관련해서도 아쉬운 부분이 많다고 느끼고 있었습니다.
그래서 한동안은 Obsidian Publish를 임시 방편으로 사용하고 있었습니다.
그러다 점점, 계속 커스터마이즈하고 싶다는 갈증이 쌓이기 시작했고, 구정을 맞아 Claude Code와 함께 뚝딱 만들어보았습니다.
Next.js 프로젝트 생성
pnpm은 빠르기도 하고, 요즘 크게 이슈도 없어서 자주 사용하고 있습니다.
npx create-next-app@latest --use-pnpm- Typescript
- Biome
- Tailwind CSS 로 설정했습니다.
✔ What is your project named? … soulee.dev
✔ Would you like to use TypeScript? … Yes
✔ Which linter would you like to use? › Biome
✔ Would you like to use React Compiler? … No
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the import alias (`@/*` by default)? … NoNextra 설치
pnpm add nextra @types/mdx공식 문서에서는 nextra-theme-blog 설치도 안내하고 있지만, 말 그대로 블로그 테마이므로 반드시 설치할 필요는 없습니다.
next.config.ts
import nextra from "nextra";
const withNextra = nextra({});
// withNextra 자체가 아니라 호출 결과를 export
export default withNextra({});mdx-components.tsx
프로젝트 루트에 mdx 사용을 위해 mdx-components.tsx 파일을 생성해야 합니다.
import type { MDXComponents } from "mdx/types";
export function useMDXComponents(components?: MDXComponents) {
return {
...components,
};
}catch-all 라우트
Nextra는 content/ 디렉토리에 있는 MDX 파일을 페이지로 변환해주는데,
이를 위해 App Router의 catch-all 라우트가 필요합니다.
Next.js에는 두 가지 catch-all 방식이 있습니다.
| 패턴 | 매칭 범위 | / (루트) 매칭 |
|---|---|---|
[...slug] | /a, /a/b, /a/b/c … | X |
[[...slug]] | /, /a, /a/b … | O |
[[...slug]](optional catch-all)은 루트(/)까지 매칭하기 때문에,
app/page.tsx가 따로 있으면 충돌이 발생합니다.
이 블로그에서는 홈(app/page.tsx), 포스트 목록(app/posts/page.tsx) 등
정적 페이지가 따로 존재하므로, MDX 전용으로 [...mdxPath](required catch-all)을 사용합니다.
이렇게 하면 루트는 app/page.tsx가 처리하고, 나머지 MDX 경로만 catch-all이 담당합니다.
프로젝트의 폴더 구조는 다음과 같습니다.
├── app/
│ ├── [...mdxPath]/
│ │ └── page.tsx ← MDX catch-all 라우트
│ ├── page.tsx ← 홈 (/ 루트, catch-all보다 우선)
│ ├── posts/
│ │ └── page.tsx ← 포스트 목록 (/posts)
│ ├── tags/
│ │ └── [tag]/
│ │ └── page.tsx ← 태그별 필터 (/tags/xxx)
│ ├── about/
│ │ └── page.tsx ← 소개 페이지 (/about)
│ ├── projects/
│ │ └── page.tsx ← 프로젝트 페이지 (/projects)
│ ├── og/
│ │ └── [...path]/
│ │ └── route.tsx ← OG 이미지 생성
│ ├── layout.tsx
│ └── globals.css
├── content/
│ └── posts/
│ └── building-blog-with-nextra.mdx ← MDX 포스트
├── mdx-components.tsx
└── next.config.tscontent/posts/에 MDX 파일을 추가하면,
Nextra가 자동으로 /posts/파일명 경로에 페이지를 생성합니다.
예를 들어 content/posts/hello-world.mdx는 /posts/hello-world로 접근할 수 있습니다.
Nextra에서는 importPage와 generateStaticParamsFor를 제공하는데,
처음에는 이를 모르고 직접 MDXContent를 사용하려다가
ReferenceError: MDXContent is not defined에러를 만났습니다.
app/[...mdxPath]/page.tsx
import { generateStaticParamsFor, importPage } from "nextra/pages";
import { useMDXComponents as getMDXComponents } from "@/mdx-components";
type Props = {
params: Promise<{ mdxPath: string[] }>;
};
export const generateStaticParams = generateStaticParamsFor("mdxPath");
export default async function PostPage(props: Props) {
const params = await props.params;
const {
default: MDXContent,
toc,
metadata,
sourceCode,
} = await importPage(params.mdxPath);
const Wrapper = getMDXComponents().wrapper;
if (!Wrapper) {
return <MDXContent {...props} params={params} />;
}
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<MDXContent {...props} params={params} />
</Wrapper>
);
}포스트 타입 정의
Nextra의 normalizePages가 반환하는 Item 타입은
frontMatter가 Record<string, any>로 느슨하게 정의되어 있습니다.
그래서 블로그에서 사용할 frontmatter 구조를 직접 정의했습니다.
// lib/types.ts
import type { Item } from "nextra/normalize-pages";
export type PostFrontMatter = {
title: string;
date: string;
tags: string[];
description?: string;
};
export type Post = Omit<Item, "frontMatter"> & {
frontMatter: PostFrontMatter;
};이 타입을 기반으로 포스트 목록을 가져오는 함수를 작성했습니다.
// lib/get-posts.ts
import { normalizePages } from "nextra/normalize-pages";
import { getPageMap } from "nextra/page-map";
import type { Post } from "./types";
export async function getPosts(): Promise<Post[]> {
const { directories } = normalizePages({
list: await getPageMap("/posts"),
route: "/posts",
});
return directories
.filter((post) => post.name !== "index")
.sort((a, b) => {
const dateA = a.frontMatter?.date
? new Date(a.frontMatter.date).getTime()
: 0;
const dateB = b.frontMatter?.date
? new Date(b.frontMatter.date).getTime()
: 0;
return dateB - dateA;
}) as Post[];
}Nextra의 FrontMatter가 Record<string, any>로 정의되어 있어
as Post[] 캐스팅이 필요합니다.
frontmatter는 직접 작성하는 MDX 파일이므로 신뢰 가능한 데이터라 보고 우선 캐스팅으로 처리 하도록 하였습니다.
Biome + lint-staged로 코드 품질 강제화
create-next-app에서 Biome을 선택하면 기본 설정이 생성됩니다.
커밋 시 format + lint를 강제하려면 husky와 lint-staged를 추가하면 됩니다.
pnpm add -D lint-staged
npx husky init.husky/pre-commit
pnpm lint-stagedpackage.json
"lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": "biome check --write --no-errors-on-unmatched"
}이렇게 설정하면 커밋 시 staged 파일에 대해 Biome이 자동 실행되고, 문제가 있을 경우 커밋이 차단됩니다.
--write 옵션 덕분에 자동 수정 가능한 부분은 알아서 정리됩니다.
배포
처음에는 Cloudflare Workers에 배포하려고 했습니다. 무료 플랜에 엣지 SSR이 가능하다는 점이 매력적이었거든요.
@opennextjs/cloudflare 어댑터를 설정하고 빌드까지는 성공했지만, 배포 단계에서 막혔습니다.
Your Worker exceeded the size limit of 3 MiB.Cloudflare Workers 무료 플랜은 Worker 크기가 3MB로 제한되어 있는데, Next.js + Nextra 번들이 이를 초과했습니다. 유료 플랜($5/mo)으로 올리면 10MB까지 가능하지만, 포스트 하나짜리 개인 블로그에 월 $5는 좀 과하다고 느꼈습니다.
결국 Vercel로 방향을 틀었습니다. Next.js를 만든 회사답게 별도 어댑터 없이 바로 배포할 수 있고, Hobby(무료) 플랜도 개인 블로그에는 충분합니다.
| Vercel (Hobby) | Cloudflare Workers (Free) | |
|---|---|---|
| SSR | O | O (3MB 제한) |
| 대역폭 | 월 100GB | 무제한 |
| 함수 크기 | 50MB | 3MB |
Vercel 배포는 간단합니다. vercel.com에서 Git 저장소를 연결하면 프레임워크를 자동 감지하고, main 브랜치에 push할 때마다 빌드 및 배포가 진행됩니다.
나중에 블로그 규모가 커지면 Cloudflare를 다시 고려해볼 생각입니다.
배포 시 만난 이슈들
CI 환경에서 빌드할 때 몇 가지 이슈를 만났습니다.
pnpm workspace 에러
ERROR packages field missing or emptycreate-next-app이 생성한 pnpm-workspace.yaml에 packages 필드가 없어서 발생했습니다. 이 프로젝트는 모노레포가 아니므로 빈 배열을 추가하면 됩니다.
packages: []
ignoredBuiltDependencies:
- sharp
- unrs-resolverhusky not found 에러
sh: 1: husky: not found
ELIFECYCLE Command failed.prepare 스크립트에서 husky를 실행하는데, CI 환경에는 husky가 설치되어 있지 않아 발생한 에러입니다. husky || true로 변경하면 로컬에서는 정상 동작하고, CI에서는 에러 없이 넘어갑니다.
{
"scripts": {
"prepare": "husky || true"
}
}