Full API reference: https://api.mailoo.app/docs/v1
This page shows how to integrate Mailoo's public Blog API into a Next.js app: list via BFF, article by slug via BFF, no API key on the client. Prefer @mailoo/blog --- see nextjs-packages{.interpreted-text role="doc"}.
Prerequisites
- A Mailoo project with a Blog integration and at least one published article
- The project UID, integration ID, and an API key (dashboard → project → API Keys)
The exact values for MAILOO_BLOG_PROJECT_UID and MAILOO_BLOG_INTEGRATION_ID (and a copyable .env example) are shown on the integration settings page in the dashboard (Connection (External API) block). Use an API key with Blog (external read) scope. For visitor like/dislike/feedback, also allow blog.article-mark on that key (or a separate restricted key used only by the marks BFF routes).
Environment (server-only)
Add to .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>
Use server-side only; do not use NEXT_PUBLIC_* for API URL or key.
For the installable @mailoo/blog / @mailoo/next-core modules (GitLab Package Registry, route factories, components), see nextjs-packages{.interpreted-text role="doc"}.
List Page (e.g. app/blog/page.tsx)
Prefer the server client (direct Mailoo API from RSC --- no BFF loopback). Check ok so a down API shows feature-local error UI instead of crashing the page (see 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>
)
}
Using BFF routes (same app exposes /api/v1/blog) --- wrap fetch so network refusal cannot 500 the 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>
)
}
Optional: Categories (e.g. for filters)
To show category filters or links, fetch categories from the BFF with the same isolation pattern (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 {
// feature-local: skip filters when the API is down
}
Use categoryId in the list query when filtering: /api/v1/blog?categoryId=... (see blog-headless-cms{.interpreted-text role="doc"}).
Pass ?locale= on /api/v1/blog/categories (and /api/v1/blog/tags) so display names resolve from classifier / tag locales.
Optional: Tag hubs
GET /api/v1/blog/tags returns [{ slug, name }]. Filter the article list with ?tag=<slug>. Prefer dedicated hub routes such as /{locale}/blog/tag/{slug} and /{locale}/blog/category/{slug} so every language gets structural SEO pages (not only translated articles).
System themes: about and portfolio
Mailoo reserves theme slugs about and portfolio for standalone pages that should not appear in the main blog feed. See System themes in blog-headless-cms{.interpreted-text role="doc"}.
Main feed (``app/blog/page.tsx``): GET /api/v1/blog without category or categoryId already omits about and portfolio articles --- no extra client logic is required.
Dedicated list pages: Fetch articles for a system theme with an explicit filter:
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>
)
}
Use the same pattern for ?category=about. You may also pass categoryId from GET /api/v1/blog/categories.
Project detail page: A portfolio item is a normal published article. Use the same BFF route as the blog post page: GET /api/v1/blog/slug/{slug} (including linkedLinks for related release notes).
Category filter chips on the main feed: GET /api/v1/blog/categories includes system themes. Hide them from filter UI so users do not expect about / portfolio in the main feed:
const SYSTEM_THEME_SLUGS = new Set(['about', 'portfolio'])
const feedCategories = categories.filter(
(c: { slug: string }) => !SYSTEM_THEME_SLUGS.has(c.slug)
)
Article Page (e.g. app/blog/[slug]/page.tsx)
Use BFF route /api/v1/blog/slug/[slug] or call the API with X-API-Key server-side. Return 404 if not found. For localized article content, pass the current locale in the query (e.g. ?locale=en); see blog-headless-cms{.interpreted-text role="doc"}.
SEO metadata: Prefer post.metaTitle ?? post.title, post.metaDescription ?? post.excerpt, a host-proxied post.ogImageUrl (else first body image --- same proxy), and post.canonicalUrl (else build from site origin + locale + slug) in generateMetadata (set alternates.canonical). Pass imageProxyPrefixWithIdEquals to createMailooBlogClient (or call rewriteMailooBlogMediaUrls) so cover and body images use your host proxy instead of api.mailoo.app. The API key needs blog.external-read and image.external-read.
A site-relative prefix is copied onto rewritten ogImageUrl and image URLs in htmlContent / content / JSON-LD. Leave those paths as <img src>. Do not pass post.ogImageUrl into openGraph.images as-is. og:image, twitter:image, and JSON-LD image / logo must be absolute against the current request origin (X-Forwarded-Host or Host, plus X-Forwarded-Proto). Do not bake a fixed SITE_URL into the client options. Without metadataBase, Next.js emits http://localhost:3000/….
Facebook requires an absolute https:// URL; MIME image/jpeg, image/gif, or image/png; and og:image:type, og:image:width, og:image:height so the first share can render without waiting on download (min 200×200 px, max 8 MB). The package does not supply width/height --- probe the proxy (Range bytes=0-524288). See Facebook image tags and Sharing for Webmasters.
Allow the image proxy path in the * group. That is enough for Twitter, LinkedIn, and RFC 9309 longest-match. It is not enough for Facebook: Facebook treats Disallow: /api/ as stronger than Allow: /api/v1/mailoo-image and reports a synthetic 403 (then "corrupt image") even when the origin returned HTTP 200. A 200 from curl is not "Facebook accepted the image."
Add a dedicated group User-agent: facebookexternalhit / Allow: /. Do not expect that group to override Disallow: /api/ from *. Either serve the proxy outside /api/ (for example /media/mailoo-image) or omit Disallow: /api/. After a robots.txt change, scrape in the Sharing Debugger in this order: robots.txt, then the image URL, then the article. The debugger warning fb:app_id is Insights, not the empty preview --- ignore it unless the host already has a Facebook App.
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
Allow: /api/v1/mailoo-image
Disallow: /_next/
import { headers } from 'next/headers'
async function requestOrigin(): Promise<string> {
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
if (!host) {
throw new Error(
'Request host is required to build absolute og:image URLs'
)
}
const proto = h.get('x-forwarded-proto') ?? 'https'
return `${proto}://${host}`
}
function absoluteSocialImageUrl(
imageUrl: string,
origin: string
): string {
if (/^https?:\/\//i.test(imageUrl)) return imageUrl
return new URL(imageUrl, `${origin}/`).toString()
}
async function probeOgImage(imageUrl: string): Promise<{
url: string
secureUrl: string
type: 'image/jpeg' | 'image/gif' | 'image/png'
width: number
height: number
}> {
const res = await fetch(imageUrl, {
headers: { Range: 'bytes=0-524288' },
})
if (!res.ok && res.status !== 206) {
throw new Error(`og:image probe failed: HTTP ${res.status}`)
}
const type = (res.headers.get('content-type') ?? '')
.split(';')[0]
?.trim()
.toLowerCase()
if (
type !== 'image/jpeg' &&
type !== 'image/gif' &&
type !== 'image/png'
) {
throw new Error(
`og:image type must be image/jpeg, image/gif or image/png, got ${type || 'missing'}`
)
}
// Host: parse PNG IHDR / JPEG SOF / GIF header of the body.
const { width, height } = parseImageDimensions(
new Uint8Array(await res.arrayBuffer())
)
return { url: imageUrl, secureUrl: imageUrl, type, width, height }
}
// generateMetadata:
const origin = await requestOrigin()
const absolute = post.ogImageUrl
? absoluteSocialImageUrl(post.ogImageUrl, origin)
: undefined
const image = absolute ? await probeOgImage(absolute) : undefined
return {
openGraph: {
images: image
? [
{
url: image.url,
secureUrl: image.secureUrl,
type: image.type,
width: image.width,
height: image.height,
},
]
: undefined,
},
twitter: {
images: image ? [image.url] : undefined,
},
}
Optional JSON-LD: GET /api/v1/blog/slug/{slug}/json-ld --- if placeholders remain, replace {{canonicalUrl}} / {{origin}} / {{locale}}; rewrite embed image URLs in that JSON the same way, then make image / logo absolute against the request origin before embedding. If the integration canonical template already filled absolute page URLs, embed those as-is. Optional keywords: join post.seoWords[].word and/or post.tags. Configure the template on the Blog integration Connection & settings tab (publicBaseUrl + path pattern such as /{locale}/blog/{slug}).
IndexNOW: To ping search engines when Mailoo publishes or updates articles, implement the integrator notify endpoint and set indexNow on the Blog integration --- see 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>
)
}
Printable summary blocks
When article Markdown includes a mailoo-print fenced block (see blog-headless-cms{.interpreted-text role="doc"}), htmlContent contains <section data-mailoo-print="true"> markers. Mount BlogPrintableButtons from @mailoo/blog/client/printable next to the article body, and wrap that body in the element named by containerSelector:
'use client'
import { BlogPrintableButtons } from '@mailoo/blog/client/printable'
export function ArticlePrintButtons() {
return (
<BlogPrintableButtons
printLabel="Print summary"
sourceLabel="Source"
containerSelector="[data-mailoo-article-body]"
onPrint={({ printTitle }) => {
reportPrint(printTitle)
}}
/>
)
}
onPrint receives { section, printTitle } and runs before the print window opens. The package prints only .mailoo-printable-body, uses data-print-title (otherwise document.title) as the document title, and, when sourceLabel is set, appends a source block. Omit containerSelector to enhance every [data-mailoo-print] on the page.
Visitor marks (like / dislike / feedback)
Slug detail responses include public likeCount / dislikeCount / feedbackCount. Feedback texts are private (dashboard / MCP read only).
-
Give the server key scope
blog.article-mark(in addition toblog.external-read). -
Mount BFF handlers from
@mailoo/blog/routes:
-
createBlogReactionHandler→app/api/v1/blog/slug/[slug]/reaction/route.ts -
createBlogFeedbackHandler→…/feedback/route.tsOr use
createMailooBlogRoutes()which includesreactionandfeedback.
-
On the article page, render
BlogArticleMarksfrom@mailoo/blog/client/markswith the slug, locale, and initial counts from the post. The widget POSTs to your same-origin BFF (never the Mailoo API key in the browser) and updates counts from the response. -
Optionally render
BlogArticleSharefrom@mailoo/blog/client/sharewith the article title, interpolated share text, i18n labels, and optional absoluteurl(when omitted, each click useswindow.location.origin + pathname).
See the @mailoo/blog package README and nextjs-packages{.interpreted-text role="doc"}.
Response shape and errors
For full response fields and error codes (e.g. 404 for unknown slug), see the API reference.