Полная справка по 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). Для like/dislike/feedback добавьте область blog.article-mark и смонтируйте BFF createBlogReactionHandler / createBlogFeedbackHandler плюс виджет BlogArticleMarks (@mailoo/blog/client/marks); см. английскую blog-nextjs-example{.interpreted-text role="doc"} и nextjs-packages{.interpreted-text role="doc"}.
Окружение (только сервер)
Добавьте в .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)
Предпочтительнее серверный клиент (прямой вызов Mailoo API из RSC --- без loopback через BFF). Проверяйте ok, чтобы при недоступном API показывать локальный UI ошибки функции, а не ронять страницу (см. nextjs-packages{.interpreted-text role="doc"}):
import {
createMailooBlogClient,
getMailooBlogConfigFromEnv,
} from '@mailoo/blog/server'
export default async function BlogPage() {
const config = getMailooBlogConfigFromEnv()
if (!config) return <div>Blog is not configured</div>
const result = await createMailooBlogClient(config).listPosts({ limit: 20 })
if (!result.ok) return <div>Failed to load blog</div>
const posts = result.data
return (
<div>
<h1>Blog</h1>
<ul>
{posts.map((p) => (
<li key={p.id}><a href={`/blog/${p.slug}`}>{p.title}</a></li>
))}
</ul>
</div>
)
}
Через BFF-маршруты (приложение предоставляет /api/v1/blog) --- оберните fetch в try/catch, чтобы отказ сети не давал 500 на RSC:
export default async function BlogPage() {
let res: Response
try {
res = await fetch('/api/v1/blog?limit=20', { next: { revalidate: 60 } })
} catch {
return <div>Failed to load blog</div>
}
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 с тем же паттерном изоляции (try/catch + res.ok):
let categories: Array<{ id: string; slug: string; name: string }> = []
try {
const res = await fetch('/api/v1/blog/categories', {
next: { revalidate: 60 },
})
if (res.ok) {
const json = await res.json()
categories = json.data || []
}
} catch {
// локально для функции: пропускаем фильтры, если API недоступен
}
Используйте categoryId в запросе списка при фильтрации: /api/v1/blog?categoryId=... (см. blog-headless-cms{.interpreted-text role="doc"}).
Передавайте ?locale= в /api/v1/blog/categories (и /api/v1/blog/tags), чтобы отображаемые имена брались из locales классификатора / тега.
Системные темы: about и portfolio
Mailoo резервирует slug тем about и portfolio для отдельных страниц, которые не должны попадать в основную ленту блога. См. Системные темы в blog-headless-cms{.interpreted-text role="doc"}.
Основная лента (``app/blog/page.tsx``): GET /api/v1/blog без category и categoryId уже исключает статьи about и portfolio --- дополнительная логика на клиенте не нужна.
Отдельные страницы-списки: Получите статьи системной темы с явным фильтром:
export default async function PortfolioPage() {
const res = await fetch('/api/v1/blog?category=portfolio&limit=50', {
next: { revalidate: 60 },
})
if (!res.ok) return <div>Failed to load portfolio</div>
const json = await res.json()
const projects = json.data || []
return (
<div>
<h1>Portfolio</h1>
<ul>
{projects.map((p: { id: string; title: string; slug: string }) => (
<li key={p.id}><a href={`/portfolio/${p.slug}`}>{p.title}</a></li>
))}
</ul>
</div>
)
}
Тот же подход для ?category=about. Можно также передать categoryId из GET /api/v1/blog/categories.
Страница проекта: Элемент portfolio --- обычная опубликованная статья. Используйте тот же BFF-маршрут, что и для поста блога: GET /api/v1/blog/slug/{slug} (включая linkedLinks для связанных release notes).
Чипы фильтра категорий на основной ленте: GET /api/v1/blog/categories включает системные темы. Скрывайте их в UI фильтров, чтобы пользователи не ожидали about / portfolio в основной ленте:
const SYSTEM_THEME_SLUGS = new Set(['about', 'portfolio'])
const feedCategories = categories.filter(
(c: { slug: string }) => !SYSTEM_THEME_SLUGS.has(c.slug)
)
Страница статьи (например, 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 после rewrite на прокси хоста (или первое изображение из тела, тот же прокси), и 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.status === 404) notFound()
if (!res.ok) return <div>Failed to load article</div>
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 тела статьи.
Отметки посетителей (like / dislike / feedback)
В ответе статьи по slug есть публичные likeCount / dislikeCount / feedbackCount. Тексты фидбека публично не отдаются.
- Добавьте на серверный ключ область
blog.article-mark. - Смонтируйте BFF:
createBlogReactionHandler/createBlogFeedbackHandler(илиcreateMailooBlogRoutes()). - На странице статьи отрендерьте
BlogArticleMarksиз@mailoo/blog/client/marks. - Опционально отрендерьте
BlogArticleShareиз@mailoo/blog/client/share(X / Facebook / LinkedIn / копировать ссылку).
См. README пакета @mailoo/blog и nextjs-packages{.interpreted-text role="doc"}.
Формат ответа и ошибки
Полные поля ответа и коды ошибок (например, 404 для неизвестного slug) описаны в справке API.