Практическое руководство по использованию Notion как headless CMS: редактор ведёт материалы в Notion, а сайт получает их через API и публикует с помощью Astro.

📌
Проверено 10 сентября 2026 года: актуальная версия Notion API — 2026-03-11. Технические примеры ниже основаны на официальной документации и репозитории notion-to-md; их нужно проверить в вашем проекте.

Оглавление

  1. Как устроена связка — путь контента от Notion до сайта
  2. Когда Notion подходит для CMS — возможности и ограничения
  3. Полезные сценарии — публикация страниц и автоматический ребилд
  4. Подключение к API — токены, права, запросы и пагинация
  5. Интеграция с Astro — коллекция контента и генерация страниц
  6. Структура контентной базы — рекомендуемые свойства
  7. Конвертация блоков — Markdown, HTML и вложенный контент
  8. Работа с файлами — временные URL и локальное сохранение
  9. Лимиты API — актуальные ограничения и повторные запросы
  10. Автоматическое обновление — webhook, расписание и polling
  11. Типичные проблемы — причины и способы проверки
  12. Быстрый старт — минимальная последовательность действий

Как устроена связка Notion и сайта

💡
Headless CMS — система, в которой редактор контента отделён от сайта. Notion хранит страницы и свойства, а внешний фронтенд самостоятельно определяет дизайн, маршруты и способ публикации.
flowchart LR
    A[Notion: редактирование] -->|Notion API| B[Astro: загрузка контента]
    B -->|build| C[Статические HTML/CSS/JS]
    C -->|deploy| D[Хостинг]
    D -->|HTTPS| E[Посетитель]

Для статического сайта поток выглядит так:

  1. Редактор меняет страницу в Notion.
  2. Во время сборки загрузчик запрашивает записи и содержимое страниц.
  3. Astro создаёт маршруты и статические файлы.
  4. Результат отправляется на хостинг.
  5. После следующего изменения контента сайт нужно собрать повторно.

Astro также поддерживает коллекции с живыми данными (live content collections), которые получают данные во время запроса. Они позволяют обойтись без постоянных ребилдов, но требуют режима выполнения сайта, который обрабатывает запросы, могут увеличить время ответа и не поддерживают часть возможностей коллекций с загрузкой при сборке, включая обработку MDX и оптимизацию изображений во время запроса. Для статей и документации обычно удобнее загрузка при сборке.


Когда Notion подходит для CMS

Связка полезна, если команда уже ведёт материалы в Notion и готова поддерживать отдельный фронтенд.

  • Блочный редактор удобен для совместной подготовки страниц.
  • Источники данных позволяют хранить записи с типизированными свойствами.
  • REST API предоставляет страницы, свойства, блоки, файлы и поиск.
  • Connection webhooks сообщают внешнему сервису об изменениях.
  • Astro может загрузить удалённый контент в коллекцию и проверить его схему.
⚖️
Компромисс: Notion не является специализированной CMS. Нужно самостоятельно реализовать преобразование блоков, обработку файлов, кэш, повторные запросы, сборку и деплой. URL файлов, загруженных через интерфейс Notion, действуют только один час.

Профильная 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:

  1. Откройте Settings → Connections.
  2. Установите или выберите connection.
  3. На нужной странице или базе откройте ••• → Add connections.
  4. Добавьте connection к объекту, который должен читать загрузчик.
  5. Проверьте, что включена возможность чтения контента (read content capability).
⚠️
Правильного токена недостаточно: connection должен иметь доступ к конкретной странице или базе. Для чтения дочерних блоков также требуется возможность чтения контента.

Основные эндпоинты

ЭндпоинтМетодНазначение
/v1/data_sources/{id}/queryPOSTЗапросить страницы источника данных
/v1/pages/{id}GETПолучить свойства страницы
/v1/blocks/{id}/childrenGETПолучить первый уровень дочерних блоков
/v1/searchPOSTИскать доступные 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-санитайзер.

Структура контентной базы

СвойствоТипНазначение
NameTitleЗаголовок страницы
SlugTextУникальная часть 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 marked
const 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Файл загружен через интерфейс NotionURL действует один час
file_uploadФайл загружен через File Upload APIВ объекте хранится ID загрузки
externalПубличный внешний HTTPS URLВозвращается без изменения и не истекает со стороны Notion
🔴
Не сохраняйте временный URL объекта 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

📌
Актуально на 10 сентября 2026 года: с 9 сентября лимит connection зависит от тарифа и считается в фиксированном 60-секундном окне.
ОграничениеЗначение
Business и Enterprise600 запросов в минуту на 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

  1. Откройте настройки connection и вкладку Webhooks.
  2. Создайте подписку и укажите публичный HTTPS URL. localhost недоступен Notion.
  3. Выберите события, например page.content_updated.
  4. Получите verification_token из первого POST и подтвердите подписку в интерфейсе.
  5. Сохраните токен как секрет обработчика.
  6. Проверяйте X-Notion-Signature по исходным байтам тела запроса.
  7. После проверки подписи получите актуальный объект через 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;
⚠️
Передавайте исходное тело запроса. Повторная сериализация JSON меняет байты и нарушает HMAC-SHA256 проверку. Сравнение подписи должно выполняться за постоянное время; helper SDK делает это самостоятельно.

Сборка по расписанию

Если публичного обработчика нет, запускайте 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 или достигнут лимит workspaceConnection страницы, capabilities и сообщение API
Пустое тело страницыПолучены свойства вместо блоков/blocks/{id}/children и пагинацию
Нет вложенного содержимогоЗагружен только первый уровеньРекурсию для has_children: true
Картинки ломаются через часВ HTML сохранён временный URLЛокальное скачивание или повторное получение file object
В выборке не все записиНе обработана пагинация или лимит 10 000has_more, next_cursor и request_status
Webhook не приходитПодписка не активна или connection не видит объектСтатус подписки, доступ, capabilities и тип события
Подпись не совпадаетJSON был разобран и сериализован зановоПередачу raw body в HMAC или SDK helper

После первой настройки проверьте наблюдаемый результат:

  1. npm run build завершается без ошибки.
  2. В каталоге сборки появляется HTML тестовой записи.
  3. В HTML присутствует актуальный заголовок страницы.
  4. Notion-hosted изображение доступно по локальному URL сайта.
  5. После изменения записи и повторной сборки HTML обновляется.
  6. В webhook-сценарии журнал фиксирует POST, успешную проверку подписи и запуск CI.

Эти проверки нужно выполнить в конкретном проекте: наличие корректных примеров в руководстве не подтверждает работоспособность вашей конфигурации.


Быстрый старт

  1. Создайте PAT или connection с доступом к Notion API.
  2. Если используете connection, добавьте его к нужной странице или базе и включите чтение контента.
  3. Установите зависимости:
npm install @notionhq/client@^5.23.0 notion-to-md marked
  1. Добавьте секреты в локальное окружение и CI:
NOTION_TOKEN=ntn_xxxxxxxxxxxx
NOTION_KB_DATA_SOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  1. Реализуйте загрузчик с пагинацией, обработкой ошибок и рекурсивным чтением блоков.
  2. Подключите загрузчик и Zod-схему в src/content.config.ts.
  3. Скачивайте Notion-hosted файлы во время сборки.
  4. Запустите npm run build и проверьте сгенерированный HTML.
  5. Измените тестовую страницу и повторите сборку.
  6. Для автоматизации подключите проверенный webhook либо workflow по расписанию.

Полезные ссылки


Следующий шаг

Notion как рабочая база, а не просто заметки поможет спроектировать понятную структуру страниц и баз до подключения сайта.

Если вы выбираете контентный стек или настраиваете публикацию из Notion, обсуждение поможет сопоставить требования редакторов, инфраструктуру и допустимую задержку обновления.

Если захотите обсудить, как это применить у себя или в команде — пишите в Telegram @pimenov