Практическое руководство по использованию Notion как headless CMS: редактор ведёт материалы в Notion, а сайт получает их через API и публикует с помощью Astro.
2026-03-11. Технические примеры ниже основаны на официальной документации и репозитории notion-to-md; их нужно проверить в вашем проекте.Оглавление
- Как устроена связка — путь контента от Notion до сайта
- Когда Notion подходит для CMS — возможности и ограничения
- Полезные сценарии — публикация страниц и автоматический ребилд
- Подключение к API — токены, права, запросы и пагинация
- Интеграция с Astro — коллекция контента и генерация страниц
- Структура контентной базы — рекомендуемые свойства
- Конвертация блоков — Markdown, HTML и вложенный контент
- Работа с файлами — временные URL и локальное сохранение
- Лимиты API — актуальные ограничения и повторные запросы
- Автоматическое обновление — webhook, расписание и polling
- Типичные проблемы — причины и способы проверки
- Быстрый старт — минимальная последовательность действий
Как устроена связка Notion и сайта
flowchart LR
A[Notion: редактирование] -->|Notion API| B[Astro: загрузка контента]
B -->|build| C[Статические HTML/CSS/JS]
C -->|deploy| D[Хостинг]
D -->|HTTPS| E[Посетитель]Для статического сайта поток выглядит так:
- Редактор меняет страницу в Notion.
- Во время сборки загрузчик запрашивает записи и содержимое страниц.
- Astro создаёт маршруты и статические файлы.
- Результат отправляется на хостинг.
- После следующего изменения контента сайт нужно собрать повторно.
Astro также поддерживает коллекции с живыми данными (live content collections), которые получают данные во время запроса. Они позволяют обойтись без постоянных ребилдов, но требуют режима выполнения сайта, который обрабатывает запросы, могут увеличить время ответа и не поддерживают часть возможностей коллекций с загрузкой при сборке, включая обработку MDX и оптимизацию изображений во время запроса. Для статей и документации обычно удобнее загрузка при сборке.
Когда Notion подходит для CMS
Связка полезна, если команда уже ведёт материалы в Notion и готова поддерживать отдельный фронтенд.
- Блочный редактор удобен для совместной подготовки страниц.
- Источники данных позволяют хранить записи с типизированными свойствами.
- REST API предоставляет страницы, свойства, блоки, файлы и поиск.
- Connection webhooks сообщают внешнему сервису об изменениях.
- Astro может загрузить удалённый контент в коллекцию и проверить его схему.
Профильная headless CMS может оказаться удобнее, если проекту необходимы сложное моделирование контента, встроенный медиаконвейер, предварительный просмотр или редакционные роли, рассчитанные именно на веб-публикацию.
Полезные сценарии
Публикация статических страниц из Notion
Задача: редактор ведёт статьи в Notion, а каждая опубликованная запись становится страницей сайта.
Условия: connection или персональный токен имеет доступ к источнику данных; Astro-проект может выполнять сборку.
Действия: запросите записи со статусом публикации, получите содержимое каждой страницы, преобразуйте его в HTML и сохраните Notion-hosted изображения в каталоге сайта.
Проверяемый результат: после npm run build появляется HTML тестовой статьи, а её изображения открываются по локальным URL сайта.
Ограничение: изменение страницы в Notion не обновляет уже собранный HTML. Требуется новый билд.
Ребилд после изменения страницы
Задача: запускать сборку после редактирования без постоянного опроса API.
Условия: настроены connection webhook, публичный HTTPS-эндпоинт и автоматизация, способная запустить CI или очередь сборки.
Действия: подпишитесь на page.content_updated, подтвердите подписку через verification_token, проверяйте X-Notion-Signature и после доверенного события заново запросите страницу по entity.id.
Проверяемый результат: обработчик получает POST, проверка подписи завершается успешно, а новая сборка содержит изменённый текст.
Ограничение: события обновления контента агрегируются и могут приходить через минуту или две. Тело события (payload) содержит метаданные, поэтому актуальный контент получают отдельным запросом.
Подключение к Notion API
Токен и доступ
Notion API использует bearer-токен в заголовке Authorization. Поддерживаются installation access token внутреннего connection, OAuth-токен публичного connection и персональный токен доступа (PAT).
PAT создаётся в Developer portal. При создании выберите доступ к Notion API, срок действия 7, 30, 90, 180 дней или один год. Значение по умолчанию — один год. Скопируйте токен после создания и храните его только в секретах окружения.
Для connection:
- Откройте
Settings → Connections. - Установите или выберите connection.
- На нужной странице или базе откройте
••• → Add connections. - Добавьте connection к объекту, который должен читать загрузчик.
- Проверьте, что включена возможность чтения контента (
read contentcapability).
Основные эндпоинты
| Эндпоинт | Метод | Назначение |
/v1/data_sources/{id}/query | POST | Запросить страницы источника данных |
/v1/pages/{id} | GET | Получить свойства страницы |
/v1/blocks/{id}/children | GET | Получить первый уровень дочерних блоков |
/v1/search | POST | Искать доступные connection-объекты |
В API версии 2026-03-11 источники данных и устаревшие database endpoints документируются отдельно. Не смешивайте идентификатор базы с data_source_id.
const response = await fetch(
`https://api.notion.com/v1/data_sources/${DATA_SOURCE_ID}/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${NOTION_TOKEN}`,
'Notion-Version': '2026-03-11',
'Content-Type': 'application/json',
},
body: JSON.stringify({
filter: {
property: 'Статус',
select: { equals: 'Опубликовано' },
},
sorts: [{ property: 'Порядок', direction: 'ascending' }],
}),
}
);
if (!response.ok) {
throw new Error(`Notion API: ${response.status}`);
}
const data = await response.json();Пагинация
Списочные endpoints используют курсоры. Передавайте полученный next_cursor как непрозрачное значение start_cursor без разбора или проверки его формата.
const pages = [];
let cursor;
let incomplete = false;
while (true) {
const response = await notion.dataSources.query({
data_source_id: DATA_SOURCE_ID,
start_cursor: cursor,
page_size: 100,
});
pages.push(...response.results);
if (response.request_status?.type === 'incomplete') {
incomplete = true;
}
if (!response.has_more || !response.next_cursor) break;
cursor = response.next_cursor;
}
if (incomplete) {
throw new Error('Notion returned an incomplete data-source query');
}Один запрос к источнику данных ограничен 10 000 результатами. Если ответ содержит request_status.type === 'incomplete' и incomplete_reason === 'query_result_limit_reached', не считайте выборку полной. Для больших источников используйте фильтры, webhooks для инкрементальной синхронизации или helpers SDK iterateAllDataSourceRows() и collectAllDataSourceRows().
Интеграция с Astro
Коллекции контента Astro умеют получать данные из удалённой CMS через пользовательский загрузчик. Для статической базы знаний используйте коллекцию с загрузкой при сборке и схему Zod.
// 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,
notionVersion: '2026-03-11',
});
const n2m = new NotionToMarkdown({ notionClient: notion });
async function loadNotionEntries(dataSourceId: string) {
const pages = await getAllPages(dataSourceId);
return Promise.all(pages.map(async (page) => {
const blocks = await n2m.pageToMarkdown(page.id);
const content = n2m.toMarkdownString(blocks).parent;
return {
id: page.id,
slug: getProperty(page, 'Slug'),
title: getProperty(page, 'Name'),
description: getProperty(page, 'Описание'),
content,
};
}));
}
export function notionLoader(dataSourceId: string) {
return {
name: 'notion-loader',
async load({ store, parseData }) {
const entries = await loadNotionEntries(dataSourceId);
store.clear();
for (const entry of entries) {
const data = await parseData({
id: entry.id,
data: entry,
});
store.set({ id: entry.id, data });
}
},
};
}notionLoader() здесь возвращает объект с load(), соответствующий текущему Content Loader API Astro. getAllPages() должна обрабатывать пагинацию, лимит 10 000 результатов и ошибки API, а getProperty() — проверять типы свойств Notion.
// 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_DATA_SOURCE_ID),
schema: z.object({
slug: z.string(),
title: z.string(),
description: z.string(),
content: z.string(),
}),
});
export const collections = { knowledgeBase };---
// src/pages/knowledge/[slug].astro
import { getCollection } from 'astro:content';
import { marked } from 'marked';
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;
const contentHtml = await marked.parse(article.data.content);
---
<BaseLayout title={article.data.title}>
<article>
<h1>{article.data.title}</h1>
<p>{article.data.description}</p>
<Fragment set:html={contentHtml} />
</article>
</BaseLayout>set:html выводит готовую HTML-строку. Если редакторы или импортируемые данные не полностью доверены, пропускайте результат Markdown-рендера через подходящий для вашего окружения HTML-санитайзер.Структура контентной базы
| Свойство | Тип | Назначение |
| Name | Title | Заголовок страницы |
| Slug | Text | Уникальная часть URL |
| Описание | Text | Описание для карточки и SEO |
| Категория | Select | Группировка материалов |
| Теги | Multi-select | Фильтрация и связанные страницы |
| Статус | Select | Черновик или Опубликовано |
| Обложка | File | Изображение карточки |
| Дата публикации | Date | Сортировка и отображение даты |
| Порядок | Number | Ручная сортировка |
Фильтруйте статус в API-запросе. Тогда черновик не попадёт в сборку из-за забытой проверки в шаблоне. Дополнительно проверяйте уникальность Slug до генерации маршрутов.
Конвертация блоков и Markdown
Notion хранит содержимое страницы в блоках. /v1/blocks/{id}/children возвращает только первый уровень и может быть разбит на страницы. Для полной структуры рекурсивно запрашивайте блоки с has_children: true.
Notion также предоставляет отдельные Markdown endpoints и расширенный формат Notion-flavored Markdown для операций чтения и записи Markdown. Он поддерживает специальные конструкции Notion, включая callout, toggle, колонки и упоминания. Обычный Markdown-рендерер может не сохранить их без дополнительного преобразования.
Для блочной модели можно использовать notion-to-md:
npm install @notionhq/client notion-to-md markedconst blocks = await n2m.pageToMarkdown(pageId);
const markdown = n2m.toMarkdownString(blocks).parent;Библиотека поддерживает пользовательские трансформеры. Они нужны, если стандартная конвертация проекта не покрывает определённый тип блока.
n2m.setCustomTransformer('callout', async (block) => {
const text = block.callout.rich_text
.map((item) => item.plain_text)
.join('');
const icon = block.callout.icon?.emoji || '💡';
return `<aside class='callout'>${icon} ${text}</aside>`;
});Экранируйте или санитизируйте значения, которые вставляете в HTML из свойств и блоков.
Работа с файлами
Notion API различает три типа источников файлов:
| Тип | Источник | Поведение |
file | Файл загружен через интерфейс Notion | URL действует один час |
file_upload | Файл загружен через File Upload API | В объекте хранится ID загрузки |
external | Публичный внешний HTTPS URL | Возвращается без изменения и не истекает со стороны Notion |
file в статическом HTML. Скачайте файл во время сборки или повторно запросите file object перед использованием ссылки.import fs from 'node:fs/promises';
import path from 'node:path';
const extensionByType = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
'image/avif': 'avif',
};
const MAX_BYTES = 10 * 1024 * 1024;
async function downloadImage(url, slug, index) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Image download failed: ${response.status}`);
}
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
throw new Error('Image is too large');
}
const contentType = (response.headers.get('content-type') || '')
.split(';', 1)[0]
.toLowerCase();
const ext = extensionByType[contentType];
if (!ext) {
throw new Error(`Unsupported image type: ${contentType || 'unknown'}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.byteLength > MAX_BYTES) {
throw new Error('Image is too large');
}
const safeSlug = slug
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'image';
const filename = `${safeSlug}-${index}.${ext}`;
const directory = path.join('public', 'images', 'content');
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(path.join(directory, filename), bytes);
return `/images/content/${filename}`;
}В рабочем загрузчике проверяйте допустимые типы и размер ответа, формируйте имена без пользовательских фрагментов пути и учитывайте форматы, которые поддерживает ваш сайт. Если URL приходит из внешних данных, добавьте проверку разрешённых хостов. Альтернатива — хранить медиа на собственном CDN и добавлять в Notion как external.
Лимиты Notion API
| Ограничение | Значение |
| Business и Enterprise | 600 запросов в минуту на connection, в среднем 10 в секунду |
| Остальные тарифы | 180 запросов в минуту на connection, в среднем 3 в секунду |
| Общий лимит workspace | Отдельный плановый лимит, общий для всех connections |
| Страница пагинации | До 100 результатов |
| Глубина query | До 10 000 результатов на один запрос источника данных |
| Notion-hosted URL | Действует один час |
При HTTP 429 и 529 читайте Retry-After, ставьте запросы в очередь и повторяйте их с ограниченным числом попыток, экспоненциальной задержкой и небольшим случайным разбросом. Серверные ошибки 500, 502, 503 и 504 безопасно повторять автоматически только для идемпотентных операций либо при собственной защите от дублей.
Для Free workspace REST API может вернуть 403 из-за лимита блоков. Это относится к internal connections и OAuth connections, ограниченным выбранными workspace. Поэтому проверяйте код и сообщение ошибки: 403 может означать как нехватку прав, так и достижение ограничения рабочего пространства. В официальных материалах на момент проверки указаны разные даты начала применения этого лимита, поэтому перед внедрением сверяйте актуальную страницу лимитов рабочего пространства.
Автоматическое обновление сайта
Connection webhook
- Откройте настройки connection и вкладку Webhooks.
- Создайте подписку и укажите публичный HTTPS URL.
localhostнедоступен Notion. - Выберите события, например
page.content_updated. - Получите
verification_tokenиз первого POST и подтвердите подписку в интерфейсе. - Сохраните токен как секрет обработчика.
- Проверяйте
X-Notion-Signatureпо исходным байтам тела запроса. - После проверки подписи получите актуальный объект через API и поставьте сборку в очередь.
В @notionhq/client версии 5.23.0 и новее есть helper verifyWebhookSignature():
import { verifyWebhookSignature } from '@notionhq/client';
const trusted = await verifyWebhookSignature({
body: rawRequestBody,
signature: request.headers['x-notion-signature'],
verificationToken,
});
if (!trusted) return;Сборка по расписанию
Если публичного обработчика нет, запускайте workflow вручную или по расписанию. Храните токен и идентификатор источника данных в секретах CI, а не в репозитории.
name: Rebuild site
on:
schedule:
- cron: '0 */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 }}
NOTION_KB_DATA_SOURCE_ID: ${{ secrets.NOTION_KB_DATA_SOURCE_ID }}Этот workflow только собирает сайт. Если нужно обновлять уже размещённые файлы, добавьте отдельный шаг деплоя по правилам вашего хостинга и заранее настройте SSH или другой способ аутентификации.
Polling по last_edited_time остаётся запасным вариантом. Он расходует лимит запросов и реагирует медленнее, поэтому для инкрементального обновления предпочтительнее webhook.
Типичные проблемы и проверка
| Проблема | Вероятная причина | Что проверить |
403 или 404 при чтении | Нет доступа, capability или достигнут лимит workspace | Connection страницы, capabilities и сообщение API |
| Пустое тело страницы | Получены свойства вместо блоков | /blocks/{id}/children и пагинацию |
| Нет вложенного содержимого | Загружен только первый уровень | Рекурсию для has_children: true |
| Картинки ломаются через час | В HTML сохранён временный URL | Локальное скачивание или повторное получение file object |
| В выборке не все записи | Не обработана пагинация или лимит 10 000 | has_more, next_cursor и request_status |
| Webhook не приходит | Подписка не активна или connection не видит объект | Статус подписки, доступ, capabilities и тип события |
| Подпись не совпадает | JSON был разобран и сериализован заново | Передачу raw body в HMAC или SDK helper |
После первой настройки проверьте наблюдаемый результат:
npm run buildзавершается без ошибки.- В каталоге сборки появляется HTML тестовой записи.
- В HTML присутствует актуальный заголовок страницы.
- Notion-hosted изображение доступно по локальному URL сайта.
- После изменения записи и повторной сборки HTML обновляется.
- В webhook-сценарии журнал фиксирует POST, успешную проверку подписи и запуск CI.
Эти проверки нужно выполнить в конкретном проекте: наличие корректных примеров в руководстве не подтверждает работоспособность вашей конфигурации.
Быстрый старт
- Создайте PAT или connection с доступом к Notion API.
- Если используете connection, добавьте его к нужной странице или базе и включите чтение контента.
- Установите зависимости:
npm install @notionhq/client@^5.23.0 notion-to-md marked- Добавьте секреты в локальное окружение и CI:
NOTION_TOKEN=ntn_xxxxxxxxxxxx
NOTION_KB_DATA_SOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx- Реализуйте загрузчик с пагинацией, обработкой ошибок и рекурсивным чтением блоков.
- Подключите загрузчик и Zod-схему в
src/content.config.ts. - Скачивайте Notion-hosted файлы во время сборки.
- Запустите
npm run buildи проверьте сгенерированный HTML. - Измените тестовую страницу и повторите сборку.
- Для автоматизации подключите проверенный webhook либо workflow по расписанию.
Полезные ссылки
- Notion API — введение
- Notion-flavored Markdown
- Получение дочерних блоков
- Connection webhooks
- Лимиты запросов
- Объект файла
- Notion API Changelog
- Astro Content Collections
- notion-to-md
Следующий шаг
Notion как рабочая база, а не просто заметки поможет спроектировать понятную структуру страниц и баз до подключения сайта.
Если вы выбираете контентный стек или настраиваете публикацию из Notion, обсуждение поможет сопоставить требования редакторов, инфраструктуру и допустимую задержку обновления.
Если захотите обсудить, как это применить у себя или в команде — пишите в Telegram @pimenov


