Blog API: Next.js Example

Last updated: Sep 13, 2026Section: Integrations

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, post.ogImageUrl (else first body image), and post.canonicalUrl (else build from site origin + locale + slug) in generateMetadata (set alternates.canonical). Optional JSON-LD: GET /api/v1/blog/slug/{slug}/json-ld --- if placeholders remain, replace {{canonicalUrl}} / {{origin}} / {{locale}}; if the integration canonical template already filled absolute URLs, embed 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. Add a small client component after the article body to inject print buttons:

'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
}

Mount <BlogPrintableButtons /> next to the dangerouslySetInnerHTML article body wrapper.

Visitor marks (like / dislike / feedback)

Slug detail responses include public likeCount / dislikeCount / feedbackCount. Feedback texts are private (dashboard / MCP read only).

  1. Give the server key scope blog.article-mark (in addition to blog.external-read).

  2. Mount BFF handlers from @mailoo/blog/routes:

  • createBlogReactionHandler β†’ app/api/v1/blog/slug/[slug]/reaction/route.ts

  • createBlogFeedbackHandler β†’ …/feedback/route.ts

    Or use createMailooBlogRoutes() which includes reaction and feedback.

  1. On the article page, render BlogArticleMarks from @mailoo/blog/client/marks with 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.

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.