Ctrl K
ComunidadGuías…

README.ko.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<div align="center">
  <a href="https://notion-as-blog.vercel.app">
    <img src="docs/screenshots/readme-hero.svg" alt="Notion-As-Blog — Notion에서 쓰고 Next.js로 배포하기" width="100%" />
  </a>

  <h1>Notion-As-Blog</h1>

  <p>
    <strong>Write in Notion. Publish with Next.js.</strong><br />
    <strong>Notion</strong>을 CMS로 쓰고 <strong>Next.js 16</strong>, <strong>Tailwind CSS 4</strong>로 배포하는 모던 블로그 템플릿입니다.
  </p>

  <p>
    <a href="https://notion-as-blog.vercel.app"><strong>공식 문서</strong></a>
    ·
    <a href="https://welcometogyuminworld.notion.site/Notion-As-Blog-30ab152141a480309a9ede1f8cac4cc7?source=copy_link"><strong>템플릿 복제</strong></a>
    ·
    <a href="#빠른-시작"><strong>빠른 시작</strong></a>
    ·
    <a href="README.md"><strong>English</strong></a>
    ·
    <strong>한국어</strong>
    ·
    <a href="README.zh-CN.md"><strong>简体中文</strong></a>
    ·
    <a href="README.ja.md"><strong>日本語</strong></a>
  </p>

  <p>
    <img alt="Next.js" src="https://img.shields.io/badge/Next.js-16-000000?style=for-the-badge&logo=next.js&logoColor=white" />
    <img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=for-the-badge&logo=typescript&logoColor=white" />
    <img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-4-38BDF8?style=for-the-badge&logo=tailwindcss&logoColor=white" />
    <img alt="Notion" src="https://img.shields.io/badge/Notion-CMS-000000?style=for-the-badge&logo=notion&logoColor=white" />
    <img alt="License" src="https://img.shields.io/badge/License-MIT-22C55E?style=for-the-badge" />
  </p>
</div>

---

## 왜 Notion-As-Blog인가요?

Notion-As-Blog는 복제한 Notion 템플릿을 빠르고 SEO 친화적인 프로덕션 블로그로 바꿔줍니다. 글 작성은 Notion 안에서 끝내고, 웹사이트는 Next.js 기반의 정적 생성, 안정적인 Notion 이미지 프록시, 검색, RSS, 사이트맵, 다크 모드, 저자 프로필까지 갖춘 형태로 배포할 수 있습니다.

<table>
  <tr>
    <td><strong>Notion 중심 작성 경험</strong><br />포스트, 페이지, 태그, 카테고리, 시리즈, 저자, 썸네일, 요약을 Notion에서 직접 관리합니다.</td>
    <td><strong>프로덕션 기본값</strong><br />Next.js App Router, 정적 생성, 이미지 프록시, RSS, sitemap, robots.txt, 동적 OG 이미지, Organization JSON-LD를 포함합니다.</td>
  </tr>
  <tr>
    <td><strong>처음부터 보기 좋은 UI</strong><br />반응형 레이아웃, 다크 모드, 포스트 애니메이션, 핀 포스트 슬라이드쇼, Giscus 댓글, 브랜딩 커스터마이징을 제공합니다.</td>
    <td><strong>셀프 호스팅 친화적</strong><br />Vercel, Docker, 자체 Node.js 호스트에서 필요한 환경 변수와 캐시 동작을 명시적으로 설정할 수 있습니다.</td>
  </tr>
</table>

---

## 미리보기

아래 이미지는 배포된 공식 문서 사이트 기준으로 촬영한 스크린샷입니다.

| 라이트 | 다크 |
|:---:|:---:|
| ![문서 홈 라이트](docs/screenshots/home-desktop.png) | ![문서 홈 다크](docs/screenshots/home-dark.png) |

<p align="center">
  <img src="docs/screenshots/post-desktop.png" alt="문서 상세" width="70%" />
  <br />
  <img src="docs/screenshots/home-mobile.png" alt="모바일 문서 홈" width="260" />
</p>

---

## 주요 기능

- **Notion CMS** — Notion에서 직접 글을 작성하고 관리합니다.
- **멀티 저자 지원** — 아바타, 소개, 역할, 소셜 링크가 포함된 Authors data source를 선택적으로 사용할 수 있습니다.
- **카테고리, 태그, 시리즈** — 카테고리 페이지, 태그 필터링, 시리즈 이전/다음 내비게이션을 제공합니다.
- **전문 검색** — 검색 API, 즉시 결과 드롭다운, 전용 검색 페이지를 포함합니다.
- **다크 모드** — 내장 theme preference hook 기반 시스템 연동 테마 전환을 제공합니다.
- **SEO 최적화** — Open Graph, 동적 OG 이미지, sitemap, robots.txt, RSS, canonical URL, Organization JSON-LD를 지원합니다.
- **안정적인 Notion 이미지** — 만료되는 Notion 업로드 파일 URL을 signed image proxy로 안정적으로 렌더링합니다.
- **Giscus 댓글** — GitHub Discussions 기반 댓글을 포스트 상세 페이지에 붙일 수 있습니다.
- **반응형 UI** — Tailwind CSS 4 기반 모바일 퍼스트 레이아웃입니다.
- **커스텀 브랜딩** — 이름, 로고, 파비콘, 색상, 폰트, 푸터 링크, 소셜 링크, 카테고리, 문구를 설정할 수 있습니다.
- **Docker 지원** — 멀티 스테이지 프로덕션 Dockerfile과 compose 예시를 포함합니다.
- **온디맨드 갱신** — 보호된 revalidation endpoint로 Notion 변경사항을 즉시 반영할 수 있습니다.

---

## 빠른 시작

### 1. Notion 템플릿 복제

아래 공개 Notion 페이지를 열고 **Duplicate**를 눌러 전체 템플릿을 본인의 Notion 워크스페이스로 복제하세요.

> **[Notion-As-Blog 템플릿 복제](https://welcometogyuminworld.notion.site/Notion-As-Blog-30ab152141a480309a9ede1f8cac4cc7?source=copy_link)**

템플릿에는 **Posts** data source, 선택 사항인 **Authors** data source, 문서형 샘플 콘텐츠가 포함되어 있습니다. 복제 후에는 모든 DB row가 본인 워크스페이스에 속하므로 자유롭게 수정하거나 삭제할 수 있습니다.

### 2. Notion Integration 생성

1. [My Integrations](https://www.notion.so/profile/integrations)에서 **New integration** 클릭
2. 이름 입력, 예: `notion-as-blog`
3. 템플릿을 복제한 워크스페이스 선택
4. **Internal Integration Secret** 복사 — 이것이 `NOTION_API_KEY`입니다

### 3. 데이터베이스에 Integration 연결

1. Notion에서 **Posts** 데이터베이스 페이지 열기
2. 우측 상단 **···** → **연결(Connections)** → 생성한 Integration 선택 후 **연결**
3. 풍부한 저자 프로필을 쓰려면 **Authors** 데이터베이스에도 동일하게 반복

### 4. Data source ID 확인

`NOTION_DATA_SOURCE_ID`에는 URL에 보이는 database ID가 아니라 **Notion data source ID**를 넣어야 합니다. 최신 Notion API에서는 database ID와 data source ID가 다를 수 있습니다.

복제한 Posts 데이터베이스를 열고 Integration을 연결한 뒤 Posts data source ID를 `NOTION_DATA_SOURCE_ID`로 설정하세요. Authors data source를 쓰는 경우 Authors data source ID를 `NOTION_AUTHORS_DATA_SOURCE_ID`로 설정합니다. database ID를 잘못 넣으면 빌드 중 `object_not_found`가 발생할 수 있습니다.

### 5. 프로젝트 설치 및 설정

```bash
git clone https://github.com/catuscio/notion-as-blog.git
cd notion-as-blog
npm install
cp .env.example .env.local
```

`.env.local` 파일을 편집합니다:

```env
NOTION_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxx
NOTION_DATA_SOURCE_ID=your_posts_data_source_id
NOTION_AUTHORS_DATA_SOURCE_ID=your_authors_data_source_id
TOKEN_FOR_REVALIDATE=any_random_secret_string
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
```

### 6. 로컬 실행

```bash
npm run dev
```

[http://localhost:3000](http://localhost:3000)에서 블로그를 확인할 수 있습니다.

---

## Notion에서 글 작성하기

### 새 포스트 작성

1. Notion에서 **Posts** 데이터베이스를 열기
2. 새 행을 추가하고 속성(property)들을 채우기
3. 페이지 본문에 글 내용을 작성 — 제목, 목록, 코드 블록, 이미지 등 모두 지원됩니다
4. 발행 준비가 되면 `status`를 **Public**으로 설정

### Posts 데이터베이스 컬럼

| 컬럼 | 타입 | 필수 | 설명 |
|---|---|---|---|
| **title** | Title | Yes | 포스트 제목 |
| **slug** | Rich text | No | URL 슬러그 (예: `my-first-post`). 비워두면 페이지 ID로 자동 생성 |
| **status** | Select | Yes | 발행 상태 (아래 참조) |
| **type** | Select | No | `Post` (기본값) 또는 `Page` |
| **date** | Date | Yes | 발행일. 이 필드를 기준으로 포스트가 정렬됩니다 |
| **category** | Select | Yes | `brand.ts`에 정의된 카테고리 이름과 일치해야 합니다 |
| **tags** | Multi-select | No | 자유 태그 (예: `Next.js`, `React`) |
| **series** | Select | No | 시리즈 이름. 같은 series 값을 선택한 포스트들은 이전/다음 네비게이션으로 연결됩니다 |
| **author** | People | No | Notion 워크스페이스 멤버. Authors data source의 `people` 속성과 Notion user ID로 매칭됩니다 |
| **summary** | Rich text | No | 포스트 카드와 SEO 메타에 표시되는 짧은 설명 |
| **thumbnail** | Files & media | No | 커버 이미지 (업로드 또는 외부 URL 붙여넣기) |

### Status 값

| 값 | 목록에 표시 | URL로 직접 접근 |
|---|---|---|
| `Public` | O | O |
| `PublicOnDetail` | X | O — 검색 색인에서도 제외되는 unlisted 콘텐츠이며 접근 제어 기능은 아님 |
| `Draft` | X | X |
| `Private` | X | X |

`PublicOnDetail`은 `Post`와 `Page`에 동일하게 적용됩니다. 피드, 검색, 카테고리·태그·시리즈·작성자 페이지, RSS, 사이트맵, 구조화 데이터, 댓글, 빌드 시점 경로 생성에서 제외됩니다. URL을 아는 사람은 누구나 접근하고 공유할 수 있으므로 민감한 콘텐츠에는 인증을 사용하세요.

### Type 값

| 값 | 설명 |
|---|---|
| `Post` | 일반 블로그 포스트. 홈 피드, 카테고리 페이지, 검색에 표시 |
| `Page` | 독립 페이지 (예: 랜딩 페이지). 포스트 목록에 표시되지 않음 |

### 시리즈 사용법

여러 포스트를 시리즈로 묶으려면, 각 포스트에서 같은 `series` 값을 선택하세요 (예: `Next.js Blog Tutorial`). 블로그는 포스트 상세 페이지에 이전/다음 링크를 렌더링하고 `/series/[name]` 페이지도 제공합니다.

### Authors data source (선택사항)

Notion 워크스페이스 멤버 이름 외에 더 풍부한 저자 프로필을 원한다면, 템플릿에 포함된 Authors data source를 사용하세요. 포스트의 `author` People 값과 Authors row의 `people` People 값이 같은 Notion user ID를 가질 때 프로필이 매칭됩니다.

| 컬럼 | 타입 | 설명 |
|---|---|---|
| **name** | Title | 저자 카드와 저자 페이지에 표시할 이름 |
| **people** | People | 이 프로필이 나타내는 Notion 워크스페이스 사용자. 포스트와 저자 프로필 매칭에 사용됩니다 |
| **role** | Rich text | 직함 또는 역할 (예: `프론트엔드 엔지니어`) |
| **bio** | Rich text | 짧은 소개글 |
| **avatar** | Files & media | 프로필 사진 |
| **email** | Rich text | 이메일 주소 |
| **github** | URL | GitHub 프로필 URL |
| **x** | URL | X (Twitter) 프로필 URL |
| **linkedin** | URL | LinkedIn 프로필 URL |
| **website** | URL | 개인 웹사이트 URL |

---

## 커스터마이징

모든 사이트 설정은 `src/config/brand.ts`에서 관리합니다:

### 사이트 정보

```ts
name: "My Blog",
title: "A Developer Blog",
highlight: "Developer",    // 타이틀에서 강조할 단어
description: "Your blog description.",
url: "https://your-domain.com",
since: 2025,               // 푸터 저작권 시작 연도
lang: "ko",
```

### 로고 & 파비콘

```ts
logo: {
  image: "",               // 로고 이미지 경로 (/public 기준). "" = 텍스트만 표시
  showNameWithLogo: true,  // 로고 옆에 블로그 이름 표시
  png: "/logo.png",        // JSON-LD, RSS 피드에 사용
  ogWhite: "/logo-white.png", // OG 이미지에 오버레이되는 흰색 로고
  favicon: "",             // 커스텀 파비콘 경로. "" = 자동 생성 글자 아이콘
},
```

### 색상

`colors` 객체에서 HSL 값으로 색상 테마를 변경할 수 있습니다. 라이트 모드와 다크 모드 색상 모두 설정 가능합니다. 각 테마는 5가지 기본 값으로 구성됩니다:

- **brand** — 강조 색상 (버튼, 링크, 포커스 링)
- **bg** — 페이지 배경
- **text** — 본문 텍스트
- **surface** — 카드 및 음소거 영역 배경
- **edge** — 테두리 및 입력 필드 외곽선

### 폰트

```ts
fonts: {
  sans: {
    stack: 'Inter, -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
  },
  mono: {
    family: "JetBrains Mono",
    cdn: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
    preconnect: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
  },
  og: {
    family: "Pretendard",
    url: "https://cdn.jsdelivr.net/.../Pretendard-Bold.otf",
  },
},
```

- **sans** — 본문 텍스트용 폰트 스택. 비라틴 언어는 웹 폰트를 앞에 추가 (예: `'Pretendard, -apple-system, ...'`)
- **mono** — 코드 블록용 고정폭 폰트. Google Fonts CDN에서 로드
- **og** — 동적 OG 이미지 생성에 사용하는 폰트 (`.otf` 또는 `.ttf` URL)

### 카테고리

카테고리 이름은 Notion Posts 데이터베이스의 Select 값과 **정확히 일치**해야 합니다:

```ts
categories: [
  { name: "Development", slug: "development", color: "orange", icon: "dns", description: "..." },
  { name: "Design", slug: "design", color: "teal", icon: "palette", description: "..." },
  { name: "Product", slug: "product", color: "green", icon: "work", description: "..." },
],
```

### 소셜 링크

푸터에 표시되는 소셜 미디어 아이콘 링크입니다. `""`으로 설정하면 해당 아이콘이 숨겨집니다.

```ts
social: {
  github: "https://github.com/your-username",
  twitter: "",
  instagram: "",
  facebook: "",
  youtube: "",
  linkedin: "",
  threads: "",
  tiktok: "",
  naverBlog: "",
},
```

### 푸터 링크

현재 푸터는 `src/components/layout/Footer.tsx`에서 단순하게 렌더링됩니다. 표시되는 항목은 다음과 같습니다.

- Home
- About
- `brand.templateUrl`로 연결되는 Template
- `brand.social`에서 URL이 비어 있지 않은 소셜 아이콘

더 큰 푸터 네비게이션이 필요하면 `Footer.tsx`를 수정하고, 라벨은 `src/config/copy.ts`에서 함께 관리하세요.

### SEO

```ts
keywords: ["Next.js", "blog", "frontend"],  // <meta name="keywords"> — []로 두면 태그 생략

organization: {   // Google 지식 패널용 Organization JSON-LD (선택사항)
  name: "Your Company",
  url: "https://your-domain.com",
  logo: "/logo.png",
  // ... address, contactPoint, sameAs 등
},
```

### Giscus 댓글

[Giscus](https://giscus.app/)를 설정한 후 config를 채우세요:

```ts
giscus: {
  repo: "your-username/your-repo",
  repoId: "R_...",
  category: "Announcements",
  categoryId: "DIC_...",
  mapping: "pathname",         // 포스트-디스커션 매핑 방식
  strict: "0",                 // 엄격한 제목 매칭
  reactionsEnabled: "1",       // 리액션 버튼 표시
  emitMetadata: "0",           // 디스커션 메타데이터 전송
  inputPosition: "bottom",     // 댓글 입력 위치
},
```

### 뉴스레터 CTA

`enabled`를 `true`로 설정하면 홈 피드 하단에 구독 섹션이 표시됩니다. 실제 구독 로직은 별도로 구현해야 합니다.

```ts
newsletter: {
  enabled: false,
  headline: "Stay ahead of the curve",
  description: "Join developers receiving the best content...",
  placeholder: "Enter your email address",
  cta: "Subscribe",
  disclaimer: "No spam, unsubscribe anytime.",
},
```

### 포스트 애니메이션

```ts
postAnimation: {
  enabled: true,  // 상세 페이지 타이프라이터 제목 + 슬라이드업 전환
},
```

### 동작 설정

```ts
postsPerPage: 10,                   // 피드 페이지당 포스트 수
slideshow: { intervalMs: 5000 },    // 고정 포스트 슬라이드쇼 자동 전환 간격 (ms)
reading: { wordsPerMinute: 200 },   // 읽기 시간 계산 (영어 200–250, CJK 500–600)
search: {
  dropdownLimit: 10,                // 검색 드롭다운 최대 결과 수
  pageLimit: 30,                    // /search 페이지 최대 결과 수
},
```

### 캐시

```ts
cache: {
  revalidate: 1800,       // ISR 갱신 간격 (초, 기본값: 30분)
  feedTtl: 3600,          // RSS Cache-Control max-age (초, 기본값: 1시간)
  authorsRevalidate: 300, // 저자 캐시 재검증 주기 (초, 기본값: 5분)
},
```

### Notion 이미지 프록시

Notion에 업로드된 파일 URL은 만료되므로, Notion 파일은 안정적인 서명 URL로 렌더링됩니다.

```txt
/api/notion-image?...
```

API route는 요청 시점에 최신 Notion 파일 URL을 다시 확인하고 이미지 bytes를 프록시합니다. 기본 프록시 캐시 TTL은 Notion signed URL의 1시간 만료보다 짧은 3300초입니다.

```env
NOTION_IMAGE_CACHE_SECONDS=3300
NOTION_IMAGE_SIGNING_SECRET=optional-separate-secret
```

외부 이미지 URL은 그대로 렌더링됩니다. `NOTION_IMAGE_SIGNING_SECRET`을 설정한다면, 정적 페이지가 생성되는 환경과 `/api/notion-image`가 실행되는 런타임 환경에서 같은 값을 사용해야 합니다. 그렇지 않으면 빌드 시 생성된 signed image URL이 런타임에서 검증되지 않을 수 있습니다.

---

## 온디맨드 갱신 (Revalidation)

블로그는 성능을 위해 Notion 데이터를 캐싱합니다. Notion에서 포스트를 수정한 후 즉시 반영하려면 다음과 같이 요청하세요:

```bash
curl -X POST https://your-domain.com/api/revalidate \
  -H "Authorization: Bearer YOUR_TOKEN_FOR_REVALIDATE"
```

Notion 자동화나 외부 서비스의 웹훅으로 연결할 수도 있습니다. 별도로 갱신을 트리거하지 않으면, 콘텐츠는 30분마다 자동으로 새로고침됩니다.

---

## 배포

### Vercel (권장)

1. GitHub에 레포지토리 푸시
2. [Vercel](https://vercel.com/new)에서 프로젝트 임포트
3. Project Settings → Environment Variables에서 환경변수 추가
4. 배포

### Docker

```bash
# docker compose로 빌드 및 실행
docker compose up -d

# 또는 직접 빌드 (NOTION_API_KEY는 정적 생성을 위해 빌드 시점에 필요)
docker build -t notion-as-blog \
  --build-arg NOTION_API_KEY=your_key \
  --build-arg NOTION_DATA_SOURCE_ID=your_data_source_id \
  .
docker run -p 3000:3000 \
  -e NOTION_API_KEY=your_key \
  -e NOTION_DATA_SOURCE_ID=your_data_source_id \
  -e NOTION_AUTHORS_DATA_SOURCE_ID=your_authors_data_source_id \
  -e TOKEN_FOR_REVALIDATE=your_revalidate_token \
  -e NOTION_IMAGE_SIGNING_SECRET=your_image_signing_secret \
  -e NOTION_IMAGE_CACHE_SECONDS=3300 \
  notion-as-blog
```

---

## 환경변수

| 변수 | 필수 | 설명 |
|---|---|---|
| `NOTION_API_KEY` | Yes | Notion Integration API 키 |
| `NOTION_DATA_SOURCE_ID` | Yes | Notion Posts data source ID |
| `NOTION_AUTHORS_DATA_SOURCE_ID` | No | Notion Authors data source ID |
| `TOKEN_FOR_REVALIDATE` | No | 온디맨드 갱신용 시크릿 토큰 (`/api/revalidate`) |
| `NOTION_IMAGE_SIGNING_SECRET` | No | 안정적인 Notion 이미지 프록시 URL을 위한 HMAC secret. 생략하면 `NOTION_API_KEY`를 사용합니다 |
| `NOTION_IMAGE_CACHE_SECONDS` | No | 프록시된 Notion 파일의 CDN 캐시 TTL. 최대 3300초로 제한됩니다 |
| `NEXT_PUBLIC_GA_ID` | No | Google Analytics 측정 ID |

---

## 라이선스

[MIT](LICENSE)