API блога: пример для Next.js

Обновлено: Aug 31, 2026Раздел: Интеграции

Полная справка по API: https://api.mailoo.app/docs/v1

На этой странице показано, как интегрировать публичный API блога Mailoo в приложение Next.js: список через BFF, статья по slug через BFF, без API-ключа на клиенте. Предпочтительнее использовать @mailoo/blog --- см. nextjs-packages{.interpreted-text role="doc"}.

Предварительные требования

  • Проект Mailoo с интеграцией Blog и хотя бы одной опубликованной статьёй
  • UID проекта, ID интеграции и API-ключ (панель управления → проект → API-ключи)

Точные значения MAILOO_BLOG_PROJECT_UID и MAILOO_BLOG_INTEGRATION_ID (и готовый пример .env) отображаются на странице настроек интеграции в панели управления (блок Подключение (внешний API)). Используйте API-ключ с областью Blog (external read).

Окружение (только сервер)

Добавьте в .env.local:

MAILOO_BLOG_API=<same value as API_BASE_URL>
MAILOO_BLOG_API_KEY=your-api-key-here
MAILOO_BLOG_PROJECT_UID=<from dashboard integration page>
MAILOO_BLOG_INTEGRATION_ID=<from dashboard integration page>

Используйте только на стороне сервера; не применяйте NEXT_PUBLIC_* для URL API или ключа.

Для устанавливаемых модулей @mailoo/blog / @mailoo/next-core (реестр пакетов GitLab, фабрики маршрутов, компоненты) см. nextjs-packages{.interpreted-text role="doc"}.

Страница списка (например, app/blog/page.tsx)

Через BFF-маршруты (приложение предоставляет /api/v1/blog):

export default async function BlogPage() {
  const res = await fetch('/api/v1/blog?limit=20', { next: { revalidate: 60 } })
  if (!res.ok) return <div>Failed to load blog</div>
  const json = await res.json()
  const posts = json.data || []
  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((p: { id: string; title: string; slug: string }) => (
          <li key={p.id}><a href={`/blog/${p.slug}`}>{p.title}</a></li>
        ))}
      </ul>
    </div>
  )
}

Дополнительно: категории (например, для фильтров)

Для отображения фильтров или ссылок по категориям получите их из BFF:

const res = await fetch('/api/v1/blog/categories', { next: { revalidate: 60 } })
const json = await res.json()
const categories = json.data || []  // [{ id, slug, name }, ...]

Используйте categoryId в запросе списка при фильтрации: /api/v1/blog?categoryId=... (см. blog-headless-cms{.interpreted-text role="doc"}).

Страница статьи (например, app/blog/[slug]/page.tsx)

Используйте BFF-маршрут /api/v1/blog/slug/[slug] или вызывайте API с X-API-Key на стороне сервера. Верните 404, если не найдено. Для локализованного контента статьи передайте текущую локаль в параметре запроса (например, ?locale=en); см. blog-headless-cms{.interpreted-text role="doc"}.

SEO-метаданные: Предпочтительнее post.metaTitle ?? post.title, post.metaDescription ?? post.excerpt, post.ogImageUrl (или первое изображение из тела), и post.canonicalUrl (или сформируйте из origin сайта + локаль + slug) в generateMetadata (задайте alternates.canonical). Опциональный JSON-LD: GET /api/v1/blog/slug/{slug}/json-ld --- если плейсхолдеры остаются, замените {{canonicalUrl}} / {{origin}} / {{locale}}; если канонический шаблон интеграции уже заполнил абсолютные URL, встраивайте как есть. Опциональные ключевые слова: объедините post.seoWords[].word и/или post.tags. Настройте шаблон на вкладке Подключение и настройки интеграции Blog (publicBaseUrl + паттерн пути, например /{locale}/blog/{slug}).

IndexNOW: Для уведомления поисковых систем при публикации или обновлении статей Mailoo реализуйте эндпоинт уведомления интегратора и настройте indexNow на интеграции Blog --- см. indexnow-notify{.interpreted-text role="doc"}.

import { notFound } from 'next/navigation'

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string; locale: string }> }) {
  const { slug, locale } = await params
  const res = await fetch(`/api/v1/blog/slug/${encodeURIComponent(slug)}?locale=${encodeURIComponent(locale)}`, { next: { revalidate: 60 } })
  if (!res.ok) notFound()
  const json = await res.json()
  const post = json.data
  if (!post?.id) notFound()
  const links = Array.isArray(post.linkedLinks) ? post.linkedLinks : []
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent || post.content }} />
      {links.length > 0 && (
        <section aria-labelledby="related-links">
          <h2 id="related-links">Related links</h2>
          {['internal_article', 'update_announcement', 'external_resource'].map((type) => {
            const group = links.filter((l: { type: string }) => l.type === type)
            if (!group.length) return null
            return (
              <div key={type}>
                <h3 className="text-sm uppercase text-gray-500">{type}</h3>
                <ul>
                  {group
                    .sort((a: { sortOrder: number }, b: { sortOrder: number }) => a.sortOrder - b.sortOrder)
                    .map((item: { id: string; label: string; url: string; intro?: string | null; date?: string | null }) => (
                      <li key={item.id}>
                        <a href={item.url.startsWith('/blog/') ? `/${locale}${item.url}` : item.url}>
                          {item.label}
                        </a>
                        {item.date && <p><time dateTime={item.date}>{new Date(item.date).toLocaleDateString()}</time></p>}
                        {item.intro && <p>{item.intro}</p>}
                      </li>
                    ))}
                </ul>
              </div>
            )
          })}
        </section>
      )}
    </article>
  )
}

Блоки печатного резюме

Если Markdown статьи содержит ограждённый блок mailoo-print (см. blog-headless-cms{.interpreted-text role="doc"}), htmlContent содержит маркеры <section data-mailoo-print="true">. Добавьте небольшой клиентский компонент после тела статьи для внедрения кнопок печати:

'use client'

import { useEffect } from 'react'

function printMailooSection(section: HTMLElement) {
  const body = section.querySelector('.mailoo-printable-body')
  if (!body) return
  const title = section.getAttribute('data-print-title')?.trim() || document.title
  const win = window.open('', '_blank', 'noopener,noreferrer')
  if (!win) return
  win.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title></head><body>${body.innerHTML}</body></html>`)
  win.document.close()
  win.focus()
  win.print()
}

export function BlogPrintableButtons() {
  useEffect(() => {
    document.querySelectorAll<HTMLElement>('[data-mailoo-print]:not([data-mailoo-print-enhanced])').forEach((section) => {
      section.setAttribute('data-mailoo-print-enhanced', 'true')
      const btn = document.createElement('button')
      btn.type = 'button'
      btn.textContent = 'Print summary'
      btn.addEventListener('click', () => printMailooSection(section))
      const actions = document.createElement('div')
      actions.className = 'mailoo-printable-actions'
      actions.appendChild(btn)
      section.insertBefore(actions, section.firstChild)
    })
  }, [])
  return null
}

Подключите <BlogPrintableButtons /> рядом с обёрткой dangerouslySetInnerHTML тела статьи.

Формат ответа и ошибки

Полные поля ответа и коды ошибок (например, 404 для неизвестного slug) описаны в справке API.