Notion как Headless CMS: контент-движок для сайта
Практическое руководство по использованию Notion в качестве headless CMS — бэкенда для контента сайта. Я использую эту связку для своего блога и базы знаний: контент редактируется в Notion, а на сайте отображается через API. Никакой админки, никаких форм — только привычный интерфейс Notion.
Лицензия: проприетарный SaaS, есть бесплатный план
Сайт: notion.so
Что такое Headless CMS
Традиционные CMS (WordPress, Tilda) — это и редактор, и сайт в одном. Headless CMS разделяет эти роли:
| Критерий | Традиционная CMS | Headless CMS |
| Редактирование | Встроенный редактор | Отдельный интерфейс (Notion, Contentful, Sanity) |
| Отображение | Привязано к CMS | Любой фронтенд (Astro, Next.js, Hugo) |
| Гибкость | Ограничена темами/плагинами | Полный контроль над кодом и дизайном |
| Скорость сайта | Зависит от CMS | Зависит от вашего фронтенда (обычно быстрее) |
Почему Notion
Notion — не специализированная CMS, но у него есть всё, что нужно для управления контентом:
- Удобный редактор — блочный, с поддержкой rich text, таблиц, кода, callout-блоков, изображений
- Базы данных — структурированное хранение с фильтрами, сортировкой, свойствами (статус, категория, теги, дата)
- API — полноценный REST API для чтения контента
- Бесплатный план — достаточен для персонального сайта
- Совместное редактирование — если работаете не в одиночку
- Привычный интерфейс — не нужно учить новый инструмент, если вы уже в Notion
Архитектура: как это работает
flowchart LR
A["Notion\n(редактирование контента)"] -->|"Notion API"| B["Astro\n(генератор сайта)"]
B -->|"npm run build"| C["HTML/CSS/JS\n(статические файлы)"]
C -->|"rsync / deploy"| D["VPS / Хостинг\n(nginx)"]
D -->|"HTTPS"| E["Посетитель сайта"]Поток данных
- Редактирование — вы пишете и редактируете контент в Notion, как обычно
- Билд — Astro через API забирает контент из Notion-баз данных
- Генерация — каждая страница превращается в статический HTML
- Деплой — готовые файлы загружаются на сервер
- Просмотр — посетители видят быстрый статический сайт
Notion API: основы
Получение токена
- Перейдите на notion.so/my-integrations
- Создайте новую интеграцию (Internal Integration)
- Скопируйте Internal Integration Secret — это ваш API-токен
- Откройте нужную базу данных в Notion → меню
...→ Connections → добавьте вашу интеграцию
Ключевые эндпоинты
| Эндпоинт | Метод | Что делает |
/v1/databases/{id}/query | POST | Получить страницы из базы с фильтрами и сортировкой |
/v1/pages/{id} | GET | Получить свойства страницы |
/v1/blocks/{id}/children | GET | Получить контент страницы (блоки) |
/v1/search | POST | Поиск по всем подключённым страницам |
Пример: запрос статей из базы
const response = await fetch(
`https://api.notion.com/v1/databases/${DATABASE_ID}/query`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${NOTION_TOKEN}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({
filter: {
property: 'Статус',
select: { equals: 'Опубликовано' },
},
sorts: [
{ property: 'Порядок', direction: 'ascending' },
],
}),
}
);
const data = await response.json();
// data.results — массив страниц с свойствамиПагинация
API возвращает максимум 100 записей за запрос. Для больших баз используйте курсор:
let allPages = [];
let cursor = undefined;
do {
const response = await notion.databases.query({
database_id: DATABASE_ID,
start_cursor: cursor,
page_size: 100,
});
allPages.push(...response.results);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);Интеграция с Astro
Notion + Astro связываются через Content Collections и кастомный лоадер.
Кастомный лоадер
Лоадер — функция, которая забирает данные из Notion API и превращает их в формат, понятный Astro:
// src/loaders/notion-loader.ts
import { Client } from '@notionhq/client';
import { NotionToMarkdown } from 'notion-to-md';
const notion = new Client({ auth: import.meta.env.NOTION_TOKEN });
const n2m = new NotionToMarkdown({ notionClient: notion });
export async function notionLoader(databaseId: string) {
const pages = await getAllPages(databaseId);
return Promise.all(
pages.map(async (page) => {
const mdBlocks = await n2m.pageToMarkdown(page.id);
const content = n2m.toMarkdownString(mdBlocks).parent;
return {
id: page.id,
slug: getProperty(page, 'Slug'),
title: getProperty(page, 'Name'),
description: getProperty(page, 'Описание'),
category: getProperty(page, 'Категория'),
tags: getProperty(page, 'Теги'),
order: getProperty(page, 'Порядок'),
publishedAt: getProperty(page, 'Дата публикации'),
content,
};
})
);
}Подключение в Content Collections
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { notionLoader } from './loaders/notion-loader';
const knowledgeBase = defineCollection({
loader: () => notionLoader(import.meta.env.NOTION_KB_DATABASE_ID),
schema: z.object({
slug: z.string(),
title: z.string(),
description: z.string(),
category: z.string(),
tags: z.array(z.string()),
order: z.number(),
publishedAt: z.string().optional(),
content: z.string(),
}),
});
export const collections = { knowledgeBase };Генерация страниц
---
// src/pages/knowledge/[slug].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const articles = await getCollection('knowledgeBase');
return articles.map((article) => ({
params: { slug: article.data.slug },
props: { article },
}));
}
const { article } = Astro.props;
---
<BaseLayout title={article.data.title}>
<article class="prose dark:prose-invert max-w-prose mx-auto">
<h1>{article.data.title}</h1>
<p class="text-gray-500">{article.data.description}</p>
<Fragment set:html={article.data.content} />
</article>
</BaseLayout>Структура базы данных для CMS
Рекомендуемая схема свойств для контентной базы:
| Свойство | Тип | Зачем |
| Name | Title | Заголовок статьи, отображается на сайте |
| Slug | Text | URL-адрес: /knowledge/astro-framework |
| Описание | Text | Мета-описание для SEO и карточек |
| Категория | Select | Группировка: Фреймворки, Инструменты, Методологии |
| Теги | Multi-select | Перекрёстная навигация и фильтрация |
| Статус | Select | Черновик / Опубликовано — фильтр при билде |
| Обложка | File | Изображение для карточки и OG-тега |
| Дата публикации | Date | Сортировка и отображение на сайте |
| Порядок | Number | Ручная сортировка внутри категории |
Конвертация блоков Notion → HTML
Notion API возвращает контент не как HTML или Markdown, а как массив блоков со своей структурой. Каждый блок — это объект с типом и данными:
{
"type": "heading_2",
"heading_2": {
"rich_text": [
{ "plain_text": "Заголовок раздела", "annotations": { "bold": false } }
]
}
}Для конвертации в Markdown или HTML используйте библиотеку notion-to-md:
npm install @notionhq/client notion-to-mdimport { NotionToMarkdown } from 'notion-to-md';
const n2m = new NotionToMarkdown({ notionClient: notion });
const mdBlocks = await n2m.pageToMarkdown(pageId);
const markdown = n2m.toMarkdownString(mdBlocks).parent;Кастомные трансформеры
Стандартная конвертация не покрывает все кейсы. Для callout-блоков, таблиц или встраиваний нужны кастомные трансформеры:
n2m.setCustomTransformer('callout', async (block) => {
const text = block.callout.rich_text
.map((t) => t.plain_text)
.join('');
const icon = block.callout.icon?.emoji || '💡';
return `<div class="callout">${icon} ${text}</div>`;
});Работа с изображениями
Notion хранит изображения двумя способами:
| Тип | Источник | Срок жизни URL |
| Загруженные в Notion | file — S3-ссылка Notion | 1 час (signed URL) |
| Внешние | external — ваша ссылка | Бессрочно |
Решение: скачивание при билде
import fs from 'fs';
import path from 'path';
async function downloadImage(url, slug, index) {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const ext = url.includes('.png') ? 'png' : 'jpg';
const filename = `${slug}-${index}.${ext}`;
const filepath = path.join('public/images/content', filename);
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, Buffer.from(buffer));
return `/images/content/${filename}`;
}Альтернатива — хранить изображения на внешнем сервисе (Cloudinary, imgix, S3) и вставлять в Notion как внешние ссылки.
Тарифы и лимиты
| Параметр | Значение |
| Rate limit | 3 запроса/сек (средняя скорость) |
| Размер запроса | Макс. 100 результатов на запрос |
| Блоки на страницу | Макс. 100 блоков за запрос (нужна пагинация) |
| Вложенность | Дочерние блоки нужно запрашивать отдельно |
| Вебхуки | Нет (нужен polling или ручной триггер ребилда) |
| Бесплатный план | API доступен, ограничения по размеру файлов (5 МБ) |
| Plus ($10/мес) | Неограниченные файлы, 30 дней истории |
Триггер ребилда
Поскольку у Notion API нет вебхуков, ребилд нужно запускать вручную или автоматизировать:
Вариант 1: Ручной (простейший)
ssh user@server 'cd /var/www/site && npm run build'Вариант 2: GitHub Actions по расписанию
# .github/workflows/rebuild.yml
name: Rebuild site
on:
schedule:
- cron: '0 */6 * * *' # каждые 6 часов
workflow_dispatch: # ручной запуск
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
env:
NOTION_TOKEN: $ secrets.NOTION_TOKEN
- name: Deploy
run: rsync -avz ./dist/ user@server:/var/www/site/Вариант 3: Webhook через n8n / Make
Настройте polling в n8n: раз в N минут проверять last_edited_time базы. Если есть изменения — триггерить GitHub Actions через API.
Сравнение с альтернативами
| Критерий | Notion | Contentful | Sanity | Strapi |
| Тип | Workspace + API | Headless CMS | Headless CMS | Self-hosted CMS |
| Редактор | ⭐ Один из лучших | Средний | Хороший | Средний |
| API | REST, базовый | REST + GraphQL | GROQ + GraphQL | REST + GraphQL |
| Вебхуки | ❌ | ✅ | ✅ | ✅ |
| CDN для медиа | ❌ (signed URL, 1 час) | ✅ | ✅ | Нужна настройка |
| Бесплатный план | Достаточен | 5 пользователей, 25K записей | 3 пользователя, 100K записей | Бесплатно (self-hosted) |
| Для кого | Уже используете Notion | Команды, продакшн | Разработчики, кастомизация | Полный контроль |
Типичные проблемы и решения
| Проблема | Причина | Решение |
| 404 при запросе базы | Интеграция не подключена к базе | Notion → база → ... → Connections → добавить интеграцию |
| Пустой контент страницы | Запрашиваете свойства, а не блоки | Используйте /blocks/{id}/children для контента |
| Сломанные картинки через час | Signed URL истёк | Скачивайте изображения при билде |
| Медленный билд | Много запросов к API | Кэшируйте ответы, используйте инкрементальный билд |
| Вложенные блоки не загружаются | API не возвращает children автоматически | Рекурсивно запрашивайте has_children: true блоки |
Быстрый старт
- Создайте интеграцию на notion.so/my-integrations
- Подключите интеграцию к нужной базе данных
- Установите зависимости:
npm install @notionhq/client notion-to-md- Создайте
.env:
NOTION_TOKEN=ntn_xxxxxxxxxxxx
NOTION_KB_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx- Напишите лоадер и подключите в
content.config.ts - Запустите билд:
npm run build
Полезные ссылки
- Notion API — документация
- Notion SDK для JavaScript
- notion-to-md — конвертер блоков в Markdown
- Astro Content Collections
- Notion API Changelog