Notion으로 나만의 블로그 만들기
On this page
이 포스팅은 Next.js 15.2 기준으로 작성되었습니다.
소스코드는Github Repo 여기에 🤓
Intro
개발자라면 개인 블로그를 직접 개발해서 운영하는 것에 대한 로망이 다들 있을 것이다. 직접 mdx를 작성하고 이를 소스코드에 포함시켜 완전한 정적 사이트로 개발하는 방법도 있겠지만, 코드로 직접 mdx작성하는 것이 번거롭기도 하고, 좀 더 작성은 쉽게 하되 정적 사이트 같이 빠른 블로그를 만들고 싶었다.
Notion API를 활용해 콘텐츠 작성의 편의성을 높이고, 정적 사이트의 성능은 유지한 블로그를 개발하였다. 그 과정을 공유해보고자 한다. (사실 그 동안 SSG, SSR, ISR의 차이를 개념적으로만 이해하고 있었는데, 블로그를 직접 만들어보면서 각각의 특징과 적절한 사용 시점을 제대로 알게 되었다.)
기술 스택
1. Framework: Next.js
블로그 콘텐츠를 Notion API를 통해 불러오면서도 정적 사이트로 제공하고 싶었기 때문에 자연스럽게 SSG, ISR을 지원하는 Next.js를 선택했다. 또한 회사에서 당연하게 사용하고 있는 Next.js에 대한 이해도를 좀 더 높이고자 학습의 의미도 있었다.
2. CSS, UI Library: Tailwind CSS, shadcn/ui
Tailwind CSS는 Utility-first CSS Framework로서 빌드 타임 purging으로 최소한의 CSS만 포함되기 때문에 Next.js와 궁합이 좋다. 퍼블리셔 출신인 나에게 Tailwind CSS의 class명 줄줄이 작성법은 꽤나 거부감이 들었었다. 하지만 익숙해진 지금은 오히려 class명을 고민하는 시간이 없어져 약간의 해방감(?)이 생겼다.
shadcn/ui는 단연 급부상 중인 Headless UI 라이브러리이다. Tailwind CSS + Radix UI 위에 구축되어 있어 Tailwind CSS와 사용하기에도 적절하다. 기본적으로 깔끔한 컴포넌트를 제공하고 그 위에 커스텀도 자유롭게 가능하기 때문에 UI 개발 시간을 단축할 수 있겠다고 생각했다.
3. CMS: Notion API
블로그 콘텐츠를 작성하고 관리하기 위해 Notion을 Headless CMS로 활용했다. Notion의 직관적인 에디터로 글을 작성하고, API를 통해 구조화된 데이터를 Next.js에서 가져와 정적 페이지로 생성한다.
4. MDX: next-mdx-remote-client
Notion에서 작성한 콘텐츠는 Notion의 블록 구조로 되어있어, 이를 블로그에 보여주려면 변환과정이 필요하다.
- Notion 블록 데이터 → 마크다운 변환 → MDX 처리 → React 컴포넌트 렌더링
이 과정에서 MDX 처리를 담당하는 것이 next-mdx-remote-client
이다. next-mdx-remote-client
는 외부 소스(API, 데이터베이스, CMS 등)에서 동적으로 가져온 MDX 콘텐츠를 Next.js 애플리케이션에서 렌더링할 수 있도록 도와준다. next-mdx-remote-client
는 next-mdx-remote
의 Fork 프로젝트인데, next-mdx-remote
는 글 작성 시점(2025.08)기준으로 마지막 업데이트가 1년 전이기도 하고 몇몇 최신 remark
, rehype
플러그인은 지원되지 않는 듯 하다. Next.js 공식문서에서도 next-mdx-remote-client
사용을 권장하고 있다.
5. Webhook: Zapier
Zapier는 서로 다른 웹 서비스들을 연결해주는 자동화 플랫폼이다. Notion에서 글을 수정해도 블로그에 반영되려면 다시 배포하거나 ISR의 revalidate 시간을 기다려야 한다. 이러한 과정을 자동화하여 최대한 실시간으로 블로그 업데이트를 하기 위해 Zapier를 도입했다. 참고로 Zapier 무료 플랜으로 월 100회까지 Zap을 실행할 수 있으며 업데이트 주기는 15분이다.
6. Comment: Giscus
Giscus는 GitHub Discussions를 백엔드로 사용하는 댓글 시스템이다. Utterances와 유사하지만 GitHub Discussions의 더 풍부한 기능을 활용할 수 있다고 한다.
7. Deploy: Vercel
Vercel은 Next.js 개발팀이 만든 배포 플랫폼이므로 Next.js의 모든 기능을 최적화된 상태로 지원한다. 또한 내장된 CI/CD 시스템으로 github push만 하면 자동으로 배포되기 때문에 선택 안 할 이유가 없다.
8. Analytics: Vercel Analytics, Google Analytics
Vercel에서는 기본적으로 Vercel Web Analytics를 제공한다. 혹시나 블로그가 커지게 되어(희망사항).. 배포 플랫폼을 마이그레이션해야하거나 통계 데이터를 유실할 경우를 대비하여 Google Analytics도 적용해두었다. (+ 성능 분석을 위해 Vercel에서 제공하는 Speed Insights도 포함)
개발 과정
여기선 기본적인 프로젝트 세팅 방법은 생략하고 노션 블로그 구축에 필요한 필수적인 정보만 작성하겠다.
1. Notion Database
1-1. Notion Integration 생성하기
먼저 Notion API를 사용하기 위해 Integration을 생성해야 한다. https://developers.notion.com/에 들어가서 우측 상단의 "View my integrations"를 클릭하여 로그인 하면 Integration을 생성할 수 있는 페이지가 나온다.
New Integration을 선택하고 Integration Name 입력, API를 사용할 개인 workspace 선택, Type은 개인 사용이므로 Intenal로 설정한 후 저장하면 끝이다! 생성이 완료되면 Internal Integration Secret이 나타나는데, 이게 바로 API key다. 복사에서 프로젝트 .env 파일에 환경변수로 작성해주자!
1-2. Notion Database 생성하고 권한 부여하기
내 노션으로 돌아와서 블로그용 페이지를 생성하고 블로그 콘텐츠를 관리할 데이터베이스 블럭을 생성해준다. 나는 아래와 같은 칼럼을 구성해서 만들었다.
데이터베이스를 생성했으면 데이터베이스 블럭이 담겨있는 페이지가 아닌 데이터베이스 자체를 전체 페이지로 열어서(중요!), 오른쪽 상단의 … 메뉴를 클릭하면 아래 이미지와 같이 “Connections”메뉴에서 앞서 만든 Integration을 선택해주면 된다.
연결 후 데이터베이스를 웹사이트로 접속해보면 URL주소가 나올텐데 여기에 데이터베이스 ID가 포함된다. https://notion.so/{workspace}/{database_id}?v={view_id}
형태로 되어 있는데, 여기서 database_id
부분을 복사해서 API key와 마찬가지로 .env파일에 환경변수를 추가한다.
NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
2. Notion API 연결
노션 데이터베이스 세팅이 끝났다면 Notion SDK를 설치하여 Notion API를 프로젝트에서 사용할 수 있도록 클라이언트 인스턴스를 생성해주어야 한다.
pnpm add @notionhq/client
import { Client } from '@notionhq/client';
const notion = new Client({
auth: process.env.NOTION_TOKEN,
});
export const database = process.env.NOTION_DATABASE_ID!;
export default notion;
이제 아래와 같이 Published
상태의 콘텐츠들만 쿼리하여 블로그 콘텐츠를 가져올 수가 있다. 자세한 API 스펙은 Notion API 공식 문서에서 확인할 수 있다.
export const getAllPublishedPosts = cache(async () => {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!,
filter: {
property: 'Status',
select: {
equals: 'Published',
},
},
sorts: [
{
property: 'CreatedAt',
direction: 'descending',
},
],
});
const pages = response.results.filter((page): page is PageObjectResponse => 'properties' in page);
const posts = await Promise.all(
pages.map(async (page) => {
return await getPostMetadata(page);
})
);
return posts;
});
3. 마크다운 변환 (feat. n2m, next-mdx-remote-client)
3-1. Notion 데이터를 마크다운으로 변환하기
notion-to-md
는 Notion의 블록 데이터를 마크다운 형태로 변환해주는 라이브러리다. Notion API로 가져온 복잡한 JSON 구조의 블록들을 일반적인 마크다운 문법으로 바꿔준다.
import { NotionToMarkdown } from "notion-to-md";
const n2m = new NotionToMarkdown({ notionClient: notion });
export const getPostBySlug = async (slug: string) => {
// ...중략... slug를 통해 pageId를 가져오는 코드
// 페이지 전체를 마크다운으로 변환
const mdBlocks = await n2m.pageToMarkdown(pageId);
const { parent } = n2m.toMarkdownString(mdBlocks);
return {
markdown: parent,
};
};
3-2. 마크다운을 MDX로 렌더링
블로그 콘텐츠를 마크다운으로 변환했다면, Next.js 애플리케이션에서 렌더링되도록 해야하는데, Next.js의 기본 MDX 지원(@next/mdx
)은 파일 시스템에 있는 .mdx
파일만 처리할 수 있다. Notion API에서 동적으로 가져온 마크다운 문자열을 렌더링 해야하므로 앞서 기술 스택에서 설명했듯이 next-mdx-remote-client
을 사용해주었다.
import { MDXRemote, MDXRemoteOptions } from 'next-mdx-remote-client/rsc';
import { components } from '@/components/mdx';
import { plugins } from '@/lib/mdx';
interface PostContentsProps {
markdown: string;
}
export default function PostContents({ markdown }: PostContentsProps) {
const options: MDXRemoteOptions = {
disableImports: true, // 보안을 위해 MDX 내에서 import 문 사용 금지
parseFrontmatter: true, // 마크다운 상단의 YAML frontmatter 파싱 활성화
vfileDataIntoScope: 'toc', // 목차(Table of Contents) 데이터를 MDX 스코프에 추가
mdxOptions: {
...plugins, // remark/rehype 플러그인들 적용
},
};
return (
<div className="prose prose-slate prose-sm dark:prose-invert prose-headings:scroll-mt-[var(--header-height)] max-w-none">
<MDXRemote source={markdown} options={options} components={components} />
</div>
);
}
3-3. MDX 내 입맛대로 꾸미기 (Plugins, Components)
블로그 만들기 전에 여러 개발자 분들의 개발 블로그를 레퍼런스 삼아 열심히 찾아보곤 했는데, 같은 mdx지만 각자 개성있는 컴포넌트를 가지고 있는 모습이 인상깊었다.
이를 가능하게 해주는 것이 MDX 플러그인 및 MDX용 컴포넌트이다. MDX 플러그인에는 크게 세 가지 종류가 있다.
- remark: 마크다운 단계에서 작동하는 플러그인.
# 제목
,**굵은글씨**
,- 리스트
같은 마크다운 문법을 처리한다. - rehype: HTML 단계에서 작동하는 플러그인.
<h1>
,<strong>
,<ul>
같은 HTML 태그를 처리한다. - recma: JavaScript 단계에서 작동하는 플러그인. 최종 컴파일된 JavaScript 코드의 AST(Abstract Syntax Tree)를 수정한다. MDX에서 생성된 JavaScript 함수나 변수를 조작할 때 사용한다.
변환 과정: 마크다운 → (remark) → HTML → (rehype) → JavaScript → (recma) → 최종 JavaScript
나는 아래와 같이 플러그인을 구성했다. 필요에 따라 더 추가할 수도 있을 것 같다. 다양한 플러그인을 여기서(remark, rehype) 확인할 수 있다.
const remarkPlugins: PluggableList = [
remarkGfm // GitHub Flavored Markdown 지원 (표, 취소선, 체크박스 등)
];
const rehypePlugins: PluggableList = [
[rehypeRaw, { passThrough: nodeTypes }], // HTML 태그를 MDX에서 직접 사용할 수 있게 허용
[rehypePrettyCode, { theme: 'vesper' }], // 코드 블록 Syntax 하이라이팅
rehypePreLanguage, // <pre> 태그에 언어 클래스 추가
rehypeSlug, // 제목(h1, h2, h3)에 자동으로 ID 생성하여 앵커 링크 가능하게 하는 플러그인
withToc, // 제목들을 분석해서 목차 데이터 추출
withTocExport, // 추출한 목차 데이터를 MDX에서 사용할 수 있게 export
];
const recmaPlugins: PluggableList = [];
export const tocPlugins: PluggableList = [
withSlugs, // 제목에 슬러그 ID 생성 (목차 전용)
withToc, // 목차 데이터 추출 (목차 전용)
withTocExport, // 목차 데이터 export (목차 전용)
];
export const plugins = {
remarkPlugins,
rehypePlugins,
recmaPlugins,
};
플러그인을 설정했다면, 컴포넌트도 커스텀해보자. MDX는 마크다운 문법을 HTML 태그로 변환할 때, 기본 HTML 요소 대신 우리가 정의한 React 컴포넌트를 사용할 수 있게 해준다. 아래와 같은 컴포넌트들을 커스텀해서 정의해줬다. 이것 또한 본인 취향에 맞게 설정해주면 된다.
export const components: MDXComponents = {
strong: (props: React.ComponentPropsWithoutRef<'strong'>) => (
<strong className="custom-strong" {...props} />
),
wrapper: (props: React.ComponentPropsWithoutRef<'div'>) => {
return <div id="mdx-layout">{props.children}</div>;
},
a: (props: React.ComponentPropsWithoutRef<'a'>) => <a {...props} target="_blank" />,
Image,
Link,
pre: Pre, // pre태그를 미리 정의한 Pre컴포넌트로 대체
blockquote: Blockquote, // blockquote태그를 Blockquote 컴포넌트로 대체
};
4. 블로그 업데이트 자동화 (feat. SSG, Zapier)
4-1. Next.js의 Static Rendering을 활용하여 정적 블로그 전환
블로그에 Notion API를 사용했을 때 문제점은 모든 데이터를 비동기로 불러오기 때문에 어쩌면 내 블로그에 접속한 사용자들이 꽤 긴 로딩시간을 겪어야 한다는 것이다. 하지만 블로그처럼 수정이 많지 않고 단순 정보 제공의 사이트는 느릴 이유가 전혀 없다. Next.js에서는 SSG(Static Site Generation)를 지원하므로, generateStaticParams
를 이용해 slug
별로 개별 포스트 페이지를 정적인 형태로 제공하기로 했다.
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPublishedPosts();
return posts.map((post) => ({ slug: post.slug }));
}
이제, 노션 CMS에서 블로그를 수정하거나 새로운 글을 작성했을 때 정적 페이지가 실시간으로 업데이트될 수 있도록 해주어야한다.
4-2. 정적 페이지를 업데이트 하기
정적 페이지를 최신 상태로 유지하는 방법은 크게 세 가지다. 첫 번째는 빌드/배포를 매 업데이트마다 하는 것이다. 단순하지만 글 하나 바뀔 때마다 전체를 재생성해야 하니 과하다. 두 번째는 ISR
(Incremental Static Regeneration)
. 정적 페이지를 기본으로 서빙하면서, 설정한 주기마다 백그라운드에서 새 HTML을 만들어 다음 요청부터 교체한다. App Router 기준으로는 export const revalidate = N
(세그먼트 설정) 또는 fetch(..., { next: { revalidate: N } })
로 “시간 기반” 재검증을 할 수 있다.
마지막으로, 내가 선택한 on-demand revalidation
(요청 시점 무효화)방식이다. 이벤트가 생겼을 때 내가 지정한 경로/태그의 캐시를 즉시 무효화하고, 다음 방문 시 새 데이터를 가져오게 만든다. App Router에서는 revalidatePath
와 revalidateTag
가 핵심이고, 이 둘은 보통 함께 쓴다. revalidateTag
로 데이터 단위를 무효화하고, revalidatePath
로 해당 화면 트리를 함께 새로고침한다. 무효화 자체는 즉시지만, 실제 재생성은 다음 요청이 들어올 때 일어난다는 점이 포인트다. 이건 서버 액션이나 Route Handler에서 호출하면 된다.
나는 블로그에 최대한 실시간 반영 + 불필요한 전체 업데이트 지양이 목표였기 때문에 SSG + on-demand revalidation 조합을 선택했다. 라우팅은 SSG로 미리 만들어 속도를 확보하고(앞서 말한 슬러그별 정적 파일), 콘텐츠 변경은 Notion → Zapier 웹훅 → revalidate
API로 연결해 곧바로 무효화한다. 이렇게 하면 정적 블로그의 최신 상태 보장이 가능하다. 나는 다음과 같이 구현했다.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import type { NextRequest } from 'next/server';
import { POST_CONTENTS } from '@/contants';
export async function POST(request: NextRequest) {
const { revalidateKey, pageId, slug } = (await request.json()) as {
revalidateKey: string;
pageId: string;
slug: string;
};
// ...
try {
// 데이터 단위 무효화(태그) + 화면 트리 무효화(경로)
revalidateTag(POST_CONTENTS(slug));
revalidatePath('/', 'page');
return Response.json({
revalidated: true,
now: Date.now(),
revalidatedPageId: pageId,
revalidatedPaths: [`/posts/${slug}`, '/'],
});
} catch (error) {
console.error('Revalidation error:', error);
return Response.json({
revalidated: false,
now: Date.now(),
message: 'Failed to revalidate due to server error.',
});
}
}
여기서 중요한 건 태그 기반 캐시 설계이다. 내가 getPostBySlug
를 unstable_cache
로 감싸고, 각 글마다 POST_CONTENTS(slug)
태그를 붙인다. 그러면 특정 글만 콕 집어 무효화할 수 있다.
// lib/notion.ts
import { unstable_cache } from 'next/cache';
import { POST_CONTENTS } from '@/contants';
export const getPostBySlug = (slug: string) => {
const key = POST_CONTENTS(slug);
return unstable_cache(
async (slug: string) => {
// Notion에서 상세 조회 → markdown 변환
// ...
return { markdown, post };
},
[key],
{ tags: [key] }
)(slug);
};
태그는 여러 캐시 엔트리를 하나의 레이블로 묶는 개념이라, 포스트 상세/요약/목록처럼 흩어진 의존성을 한 번에 갱신하기 좋다. 공식문서에 따르면 revalidateTag
는 표시된 태그의 캐시를 오래됨(stale)으로 표시만 하고, 실제 재생성은 다음 요청에서 일어난다.
4-3. Zapier에 Zap 등록하기
Zapier란?
Zapier는 서로 다른 앱들을 연결해주는 자동화 툴이다. 핵심은 이벤트 기반 동작인데, 특정 서비스에서 어떤 일이 발생하면(Trigger) Zapier가 그걸 감지해서 지정된 액션을 실행한다. 예를 들어 내가 Notion에서 새 글을 작성하면 Zapier가 웹훅을 호출하고, 이게 Next.js의 API를 트리거해서 캐시를 무효화하는 식이다.
먼저 Zapier에 로그인한 후 Zap 메뉴를 클릭하면 다음과 같이 Trigger와 Action을 등록할 수 있다.
Trigger App은 Notion, Trigger event는 Updated Database Item
을 선택하면된다. Updated Database Item
으로도 기존 글 수정 + 새로운 글 생성까지 커버가 되는걸 확인했다. Notion계정을 연결하면 기존 Integration과 연결되어 접근 가능한 블로그 데이터베이스가 나타날 것이다. 이를 선택해주면 된다.
Action단계에서는 JavaScript 코드를 실행할 수 있는데, 실제로는 Notion에서 전달받은 데이터를 검사한 뒤 내 블로그 서버에 POST 요청을 보내는 로직을 작성한다.
const slug = inputData.slug;
const status = inputData.status;
const pageId = inputData.id;
// 글의 상태(status)가 Published가 아닐 경우 중단
if (status !== 'Published') {
return { revalidated: false };
}
// slug가 규칙에 맞지 않으면 캐시 무효화하지 않고 그대로 반환
if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
return { revalidated: false, slug };
}
// 조건을 통과하면 Next.js 서버의 /api/revalidate 엔드포인트로 POST 요청
const res = await fetch("https://shmoon.dev/api/revalidate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pageId,
slug,
revalidateKey: xxxxxxxxxxxx
})
});
// 응답을 받아 revalidate 여부를 결과로 기록
const responseData = await res.json();
return { slug, revalidated: responseData?.revalidated ?? false };
여기까지하면 Zapier를 이용한 업데이트 자동화까지 완료한 것이다. 이제 Notion 템플릿에 새로운 글을 작성하면 다음과 같은 흐름으로 블로그에 업데이트 된다.
글 작성 → Zapier → Next.js API 재검증 → 캐시 무효화 → 사용자 요청 → 최신 블로그 페이지 제공, 이렇게 한 사이클로 돌아간다.
Outro
이번 블로그 개발은 양승찬님의 Notion으로 나만의 블로그 CMS 만들기와 개발자님의 코드를 많이 참고해서 구현했다. 그 과정에서 혼자 삽질하는 시간을 많이 줄였고, 길잡이가 되어 정말 많은 도움을 받았다. 이외에도 정현수님 블로그, 김도형님 블로그 등에서 UI 구성이나 글 작성 방식 등 다양한 아이디어를 참고했다. 이 글을 빌어 감사의 인사를 전하고 싶다.🙇♀️
드디어 미루고 미루던 나만의 개발 블로그를 세상에 내놓게 되어 기쁘고, 앞으로도 꾸준하게 글을 작성하는 습관을 들이려고 한다. 개발만 하는 것과 그 과정 및 개념들을 복기하며 정리하고, 기록으로 남겨두는 것은 많은 차이가 있다고 생각한다. 트래픽 한도 초과하여 마이그레이션을 고민하는 그날까지… 정진해야지. 화이팅
++
기능은 계속해서 추가하거나 보완할 예정입니다. 피드백 환영! 건의사항이 있으시다면 댓글 남겨주시면 감사하겠습니다.
긴 글 읽어주셔서 감사합니다.