Referencia completa de la API: https://api.mailoo.app/docs/v1
Esta página muestra cómo integrar la API pública de Blog de Mailoo en una aplicación Next.js: listado mediante BFF, artículo por slug mediante BFF, sin clave API en el cliente. Prefiere @mailoo/blog --- consulta nextjs-packages{.interpreted-text role="doc"}.
Requisitos previos
- Un proyecto de Mailoo con una integración Blog y al menos un artículo publicado
- El UID del proyecto, el ID de integración y una clave API (panel de control → proyecto → Claves API)
Los valores exactos de MAILOO_BLOG_PROJECT_UID y MAILOO_BLOG_INTEGRATION_ID (y un ejemplo .env copiable) se muestran en la página de ajustes de la integración en el panel de control (bloque Conexión (API externa)). Usa una clave API con alcance Blog (lectura externa).
Entorno (solo servidor)
Añade a .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>
Usa solo en el servidor; no uses NEXT_PUBLIC_* para la URL de la API ni la clave.
Para los módulos instalables @mailoo/blog / @mailoo/next-core (registro de paquetes GitLab, fábricas de rutas, componentes), consulta nextjs-packages{.interpreted-text role="doc"}.
Página de lista (p. ej. app/blog/page.tsx)
Prefiere el cliente de servidor (API de Mailoo directa desde RSC --- sin loopback BFF). Comprueba ok para que una API caída muestre UI de error local de la función en lugar de tumbar la página (consulta 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>
)
}
Usando rutas BFF (la misma aplicación expone /api/v1/blog) --- envuelve fetch en try/catch para que un rechazo de red no provoque 500 en el 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>
)
}
Opcional: categorías (p. ej. para filtros)
Para mostrar filtros o enlaces de categorías, obtén las categorías desde el BFF con el mismo patrón de aislamiento (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 {
// local a la función: omitir filtros si la API no está disponible
}
Usa categoryId en la consulta de lista al filtrar: /api/v1/blog?categoryId=... (consulta blog-headless-cms{.interpreted-text role="doc"}).
Pasa ?locale= en /api/v1/blog/categories (y /api/v1/blog/tags) para que los nombres visibles se resuelvan desde locales del clasificador / etiqueta.
Temas del sistema: about y portfolio
Mailoo reserva los slugs de tema about y portfolio para páginas independientes que no deben aparecer en el feed principal del blog. Consulta Temas del sistema en blog-headless-cms{.interpreted-text role="doc"}.
Feed principal (``app/blog/page.tsx``): GET /api/v1/blog sin category ni categoryId ya omite artículos about y portfolio --- no se requiere lógica extra en el cliente.
Páginas de lista dedicadas: Obtén artículos de un tema del sistema con un filtro explícito:
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>
)
}
Usa el mismo patrón para ?category=about. También puedes pasar categoryId desde GET /api/v1/blog/categories.
Página de detalle del proyecto: Un elemento de portfolio es un artículo publicado normal. Usa la misma ruta BFF que la página de post del blog: GET /api/v1/blog/slug/{slug} (incluido linkedLinks para release notes relacionadas).
Chips de filtro de categorías en el feed principal: GET /api/v1/blog/categories incluye temas del sistema. Ocúltalos en la UI de filtros para que los usuarios no esperen about / portfolio en el feed principal:
const SYSTEM_THEME_SLUGS = new Set(['about', 'portfolio'])
const feedCategories = categories.filter(
(c: { slug: string }) => !SYSTEM_THEME_SLUGS.has(c.slug)
)
Página de artículo (p. ej. app/blog/[slug]/page.tsx)
Usa la ruta BFF /api/v1/blog/slug/[slug] o llama a la API con X-API-Key del lado del servidor. Devuelve 404 si no se encuentra. Para contenido de artículo localizado, pasa el idioma actual en la consulta (p. ej. ?locale=en); consulta blog-headless-cms{.interpreted-text role="doc"}.
Metadatos SEO: Prefiere post.metaTitle ?? post.title, post.metaDescription ?? post.excerpt, post.ogImageUrl reescrito al proxy del host (sino primera imagen del cuerpo, el mismo proxy), y post.canonicalUrl (sino construye desde origen del sitio + idioma + slug) en generateMetadata (establece alternates.canonical). JSON-LD opcional: GET /api/v1/blog/slug/{slug}/json-ld --- si quedan marcadores, reemplaza {{canonicalUrl}} / {{origin}} / {{locale}}; si la plantilla canónica de la integración ya rellenó URLs absolutas, embébelo tal cual. Palabras clave opcionales: une post.seoWords[].word y/o post.tags. Configura la plantilla en la pestaña Conexión y ajustes de la integración de Blog (publicBaseUrl + patrón de ruta como /{locale}/blog/{slug}).
IndexNOW: Para notificar a motores de búsqueda cuando Mailoo publica o actualiza artículos, implementa el endpoint de notificación del integrador y establece indexNow en la integración de Blog --- consulta 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>
)
}
Bloques de resumen imprimible
Cuando el Markdown del artículo incluye un bloque mailoo-print (consulta blog-headless-cms{.interpreted-text role="doc"}), htmlContent contiene marcadores <section data-mailoo-print="true">. Añade un pequeño componente cliente después del cuerpo del artículo para inyectar botones de impresión:
'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
}
Monta <BlogPrintableButtons /> junto al contenedor dangerouslySetInnerHTML del cuerpo del artículo.
Marcas del visitante (like / dislike / feedback)
El detalle por slug incluye likeCount / dislikeCount / feedbackCount. Los textos de feedback no son públicos.
- Añade el alcance
blog.article-marka la clave del servidor. - Monta el BFF:
createBlogReactionHandler/createBlogFeedbackHandler(ocreateMailooBlogRoutes()). - En la página del artículo renderiza
BlogArticleMarksdesde@mailoo/blog/client/marks. - Opcionalmente renderiza
BlogArticleSharedesde@mailoo/blog/client/share(X / Facebook / LinkedIn / copiar enlace).
Consulta el README de @mailoo/blog y nextjs-packages{.interpreted-text role="doc"}.
Estructura de respuesta y errores
Para los campos de respuesta completos y códigos de error (p. ej. 404 para slug desconocido), consulta la referencia de la API.