Next.js integrator packages (`@mailoo/*`)

Last updated: Oct 12, 2026Section: Integrations

Mailoo publishes headless npm packages for Next.js App Router apps. They provide BFF route factories, server-side clients, and optional unstyled components. The host application keeps locale routing, i18n, theme, auth, and page layouts.

External Next.js hosts use the same patterns: server-only credentials, same-origin BFF, no NEXT_PUBLIC_* API keys.

A runnable local example lives in tests/integrator-demo (port 3080): start it from Dev Manager Optional or pnpm run dev:integrator-demo. See tests/integrator-demo/README.md.

Full API reference: https://api.mailoo.app/docs/v1

Package list


Package Role


@mailoo/next-core Shared helpers: env config, webhook JSON proxy, request-origin URL for RSC

@mailoo/blog Blog list/slug/json-ld/categories BFF factories, server client, article UI pieces

@mailoo/forms FORM (newsletter) and CONTACT_FORM (feedback) submit BFF + client hooks; lifecycle events (./events, ./server)

@mailoo/chat JSBOX visitor chat BFF, headless hooks, and floating widget (WebSocket to Mailoo API)

@mailoo/images Public image proxy route factory + rewriteMailooPublicImageUrls

@mailoo/market Market catalog list + email-checkout order BFF factories and server client (scaffold; offer-generator UI is a follow-up)

SOCIAL has no npm package: compose/publish runs only in the Mailoo dashboard (Bearer). External sites do not mount SOCIAL runtime --- see social-networks{.interpreted-text role="doc"}.

Versions match the monorepo VERSION (published together). Peer dependency on @mailoo/next-core for the feature packages.

What the package owns vs the host

Inside the package

  • Calling Mailoo with X-API-Key / base URL from server-only env
  • Missing env on BFF → HTTP 503 JSON (not an uncaught throw)
  • Upstream / network failure → { ok: false }, null, or HTTP 502 JSON
  • Types for API payloads
  • Optional presentation with className / label props (no next-intl)

Host application

  • Mounting route handlers under app/api/...
  • [locale] routing and Link
  • Translations, Tailwind / typography theme, Auth.js, marketing CTAs
  • Isolating integration errors so a down Mailoo API does not 500 the whole site (see below)

Host: integration errors must not take down the app

Treat Mailoo as a feature, not a hard dependency of every page.

  • Package layout/RSC helpers must not throw when the API is unreachable: they return null, { ok: false, status }, or BFF JSON 503 / 502.
  • The host must check that result before rendering and show an error for that feature only (empty catalog, "failed to load posts", omit chat shell).
  • Do not leave a bare await fetch(...) in a Server Component or root layout: a network refusal throws before res.ok is checked and can 500 the whole response.
  • Do not call Mailoo from root layout.tsx unless you use a never-throw helper (resolveMailooChatHostConfig is the reference pattern).
  • HTTP 404 (missing article) is not the same as API down --- use notFound() only for true not-found; upstream failures should stay HTTP 200 with feature-local error UI.
  • In production, MAILOO_*_API must be the public API origin (e.g. https://api.mailoo.app), not localhost.

Install from GitLab Package Registry

Packages are published to this project's GitLab Package Registry (npm), scope @mailoo.

Add an .npmrc in the consumer project (replace host / project id and use a token with read_api or read_package_registry):

@mailoo:registry=https://<gitlab-host>/api/v4/projects/<project-id>/packages/npm/
//<gitlab-host>/api/v4/projects/<project-id>/packages/npm/:_authToken=<TOKEN>

Then install the packages you need (for example with pnpm):

pnpm add @mailoo/next-core @mailoo/blog
# and/or @mailoo/forms @mailoo/chat @mailoo/images @mailoo/market

@mailoo/next-core

import {
  readMailooIntegrationConfig,
  proxyMailooWebhookJsonPost,
  jsonError,
} from '@mailoo/next-core'
import { getRequestOriginBaseUrl } from '@mailoo/next-core/request-origin'
  • readMailooIntegrationConfig({ prefix }) --- reads {PREFIX}_API, _API_KEY, _PROJECT_UID, _ID / _INTEGRATION_ID
  • proxyMailooWebhookJsonPost / proxyMailooApiGet --- POST/GET JSON with X-API-Key; network failure returns HTTP 502 JSON (never throws)
  • getRequestOriginBaseUrl (@mailoo/next-core/request-origin) --- absolute origin from request headers (RSC → BFF; keep off the main barrel so client bundles never import next/headers)

@mailoo/blog

Env (server-only):

MAILOO_BLOG_API=
MAILOO_BLOG_API_KEY=
MAILOO_BLOG_PROJECT_UID=
MAILOO_BLOG_INTEGRATION_ID=

BFF routes

// app/api/v1/blog/route.ts
import { createBlogListHandler } from '@mailoo/blog/routes'
export const GET = createBlogListHandler()
// app/api/v1/blog/slug/[slug]/route.ts
import { createBlogSlugHandler } from '@mailoo/blog/routes'
export const GET = createBlogSlugHandler()
// app/api/v1/blog/slug/[slug]/json-ld/route.ts
import { createBlogJsonLdHandler } from '@mailoo/blog/routes'
export const GET = createBlogJsonLdHandler()

Also available: createBlogCategoriesHandler, createBlogCategoryDescriptionHandler, createBlogReactionHandler, createBlogFeedbackHandler, or createMailooBlogRoutes() (list, slug, jsonLd, categories, categoryDescription, reaction, feedback).

Visitor marks BFF (scope blog.article-mark on the server key):

// app/api/v1/blog/slug/[slug]/reaction/route.ts
import { createBlogReactionHandler } from '@mailoo/blog/routes'
export const POST = createBlogReactionHandler()

// app/api/v1/blog/slug/[slug]/feedback/route.ts
import { createBlogFeedbackHandler } from '@mailoo/blog/routes'
export const POST = createBlogFeedbackHandler()

Server client / RSC

import {
  createMailooBlogClient,
  getMailooBlogConfigFromEnv,
  fetchBlogPostBySlug,
  firstEmbedImageUrlFromArticle,
} from '@mailoo/blog/server'
  • createMailooBlogClient(config) --- listPosts, getPostBySlug, getPostJsonLd, listCategories, getCategoryDescription, setReaction, setFeedback (direct Mailoo API; use from RSC / sitemap so K8s/Docker never loops back to the host BFF). GET fetches use next: { revalidate: 60 } by default; pass { revalidate: 0 } (or another number) as the last argument. Mark writes always POST without ISR cache. listPosts returns data (posts array) plus pagination and imagePublicEmbedBaseUrl (same envelope as the external list API; incomplete envelope → ok: false). Slug detail includes likeCount / dislikeCount / feedbackCount.
  • fetchBlogPostBySlug / fetchBlogPostJsonLd --- fetch via the host's same-origin BFF only

Components

import { BlogArticleBody } from '@mailoo/blog/client/article-body'
import { BlogLinkedLinksDisplay } from '@mailoo/blog/client/linked-links'
import { BlogPrintableButtons } from '@mailoo/blog/client/printable'
import { BlogArticleMarks } from '@mailoo/blog/client/marks'
import { BlogArticleShare } from '@mailoo/blog/client/share'

Or the barrel @mailoo/blog/client. Prefer subpaths when you want to avoid pulling optional peers (e.g. react-markdown) into every import. BlogArticleMarks shows public counts and POSTs reactions/feedback to your same-origin BFF (see blog-nextjs-example{.interpreted-text role="doc"}). BlogArticleShare renders X / Facebook / LinkedIn / copy-link controls; pass interpolated share text, i18n labels, and optional absolute url (when omitted, clicks use origin + pathname).

Step-by-step page examples: blog-nextjs-example{.interpreted-text role="doc"}. Product overview: blog-headless-cms{.interpreted-text role="doc"}.

@mailoo/forms

Env

  • Newsletter (FORM): MAILOO_CONTACT_FORM_INTEGRATION_{API,API_KEY,PROJECT_UID,ID} (or _INTEGRATION_ID). Omit prefix on the form handler to use this default.
  • Feedback (CONTACT_FORM): MAILOO_FEEDBACK_INTEGRATION_{API,API_KEY,PROJECT_UID,ID} Optional (same prefix): MAILOO_FEEDBACK_INTEGRATION_FORM_NAMESPACE, MAILOO_FEEDBACK_INTEGRATION_SITE_ID, MAILOO_FEEDBACK_INTEGRATION_ALLOWED_FORM_KEYS
  • Lifecycle (app registration / activation): MAILOO_LIFECYCLE_{API,API_KEY,PROJECT_UID,ID} (or _INTEGRATION_ID). Used by ingestMailooLifecycleEvent and lifecycle BFF handlers.
  • Multiple integrations: pass prefix (or getConfig) per BFF route, e.g. createFeedbackSubmitHandler({ prefix: 'MAILOO_TEAM_SUBSCRIPTION_FORM' }). Preferred config helper: getMailooFormsIntegrationConfig({ prefix }). Deprecated: getMailooFormIntegrationConfig, getMailooFeedbackIntegrationConfig, isMailooFeedbackIntegrationConfigured.

Exports

  • @mailoo/forms/routes --- createFormSubmitHandler, createFeedbackSubmitHandler, createLifecycleEventHandler, createLifecycleStatusHandler
  • @mailoo/forms/hooks --- useMailooFormSubmit, useMailooFeedbackSubmit, useMailooLifecycleEvent (hooks return { submit|emit, pending })
  • @mailoo/forms/events --- closed catalog MAILOO_LIFECYCLE_EVENTS (user.registered, user.activated) and attribute-key validators
  • @mailoo/forms/server --- ingestMailooLifecycleEvent for server-only emit

Form submit bodies are typed (email, optional name / content / ...). Already-subscribed FORM responses succeed without messageId. Lifecycle eventName and attribute keys are closed --- unknown values fail fast (400).

Routes and hooks

// app/api/v1/webhooks/forms/submit/route.ts
import { createFormSubmitHandler } from '@mailoo/forms/routes'
export const POST = createFormSubmitHandler()
// app/api/v1/webhooks/feedback/submit/route.ts
import { createFeedbackSubmitHandler } from '@mailoo/forms/routes'
export const POST = createFeedbackSubmitHandler()
// Second CONTACT_FORM integration (custom env prefix)
import { createFeedbackSubmitHandler } from '@mailoo/forms/routes'
export const POST = createFeedbackSubmitHandler({
  prefix: 'MAILOO_TEAM_SUBSCRIPTION_FORM',
})
'use client'
import { useMailooFormSubmit, useMailooFeedbackSubmit } from '@mailoo/forms/hooks'
// Point hooks at the BFF path that binds the desired integration

See also website-forms-nextjs-example{.interpreted-text role="doc"}, feedback-form-nextjs-example{.interpreted-text role="doc"}, and lifecycle-events{.interpreted-text role="doc"}.

@mailoo/chat

Env

MAILOO_CHAT_INTEGRATION_API=
MAILOO_CHAT_INTEGRATION_API_KEY=
MAILOO_CHAT_INTEGRATION_PROJECT_UID=
MAILOO_CHAT_INTEGRATION_ID=
# Optional: public API origin for WSS when it differs from the BFF target
MAILOO_CHAT_WS_ORIGIN=
MAILOO_CHAT_WELCOME_MESSAGE=

The browser opens WebSocket directly to Mailoo (/api/v1/chat/visitor-ws). Ingress must support Upgrade. See chat-websocket-production{.interpreted-text role="doc"}.

Routes, hooks, and widget

The package does not depend on Auth.js. Optionally inject session email:

import {
  createChatSubmitHandler,
  createChatMessagesHandler,
  createChatStatusHandler,
} from '@mailoo/chat/routes'

export const POST = createChatSubmitHandler({
  resolveSender: async () => {
    const session = await auth()
    return session?.user?.email
      ? { email: session.user.email, name: session.user.name ?? undefined }
      : null
  },
})

// app/api/v1/webhooks/chat/messages/route.ts
export const GET = createChatMessagesHandler()

// app/api/v1/webhooks/chat/status/route.ts
export const GET = createChatStatusHandler()
import { useMailooChatSession } from '@mailoo/chat/hooks'
import { MailooSiteChatWidget } from '@mailoo/chat/client'

<MailooSiteChatWidget
  apiOrigin={wsOrigin}
  projectUid={projectUid}
  integrationId={integrationId}
  welcomeMessage={welcome}
  locale={locale}
  isAuthenticated={isAuthenticated}
/>

Config helpers: getMailooChatIntegrationConfig, getMailooChatWebSocketOrigin, fetchMailooChatIntegrationStatus from @mailoo/chat.

Rate limit / unavailable UX (unavailableAction, renderUnavailable, fault): chat-widget-nextjs-example{.interpreted-text role="doc"}.

UI walkthrough: chat-widget-nextjs-example{.interpreted-text role="doc"}.

@mailoo/images

Public embed proxy (API key stays on the server):

// app/api/mailoo-image/route.ts
import { createPublicImageContentHandler } from '@mailoo/images/routes'

export const GET = createPublicImageContentHandler()

Defaults to MAILOO_BLOG_API / MAILOO_BLOG_API_KEY (HTTP 503 if unset). Pass getApiBaseUrl / getApiKey to override.

Rewrite absolute Mailoo public URLs in HTML/Markdown before render:

import { rewriteMailooPublicImageUrls } from '@mailoo/images'

rewriteMailooPublicImageUrls(html, {
  canonicalPrefixWithIdEquals:
    'https://api.mailoo.app/api/v1/images/public/content?id=',
  proxyPrefixWithIdEquals: 'https://yoursite.com/api/mailoo-image?id=',
})

Article objects (cover + HTML + markdown) in one call:

import { rewriteMailooBlogMediaUrls } from '@mailoo/images'

const safe = rewriteMailooBlogMediaUrls(post, {
  canonicalPrefixWithIdEquals: post.imagePublicEmbedBaseUrl,
  proxyPrefixWithIdEquals: 'https://yoursite.com/api/mailoo-image?id=',
})

Or let the blog server client rewrite list, slug, category description, and JSON-LD image URLs. Canonical prefix comes from imagePublicEmbedBaseUrl on the response. Omit the option to keep canonical URLs:

createMailooBlogClient(config, {
  imageProxyPrefixWithIdEquals: '/api/mailoo-image?id=',
})

The key behind the proxy needs image.external-read (and blog.external-read for article JSON).

Bearer dashboard-style BFF: createImagesLibraryHandlers({ getApiBaseUrl, getAuthHeader, resolveAuthHeader }). Details: images{.interpreted-text role="doc"}.

@mailoo/market

Env (server-only):

MAILOO_MARKET_API=
MAILOO_MARKET_API_KEY=
MAILOO_MARKET_PROJECT_UID=
MAILOO_MARKET_INTEGRATION_ID=

BFF routes (missing env → 503; upstream down → 502):

// app/api/v1/market/products/route.ts
import { createMarketProductsHandler } from '@mailoo/market/routes'
export const GET = createMarketProductsHandler()

// app/api/v1/market/orders/route.ts
import { createMarketOrdersHandler } from '@mailoo/market/routes'
export const POST = createMarketOrdersHandler()

Server client:

import {
  createMailooMarketClient,
  getMailooMarketConfigFromEnv,
} from '@mailoo/market/server'

const config = getMailooMarketConfigFromEnv()
if (config) {
  const list = await createMailooMarketClient(config).listProducts({
    locale: 'en',
  })
  if (!list.ok) {
    // feature-local error — do not throw
  }
}

Offer-generator wizards are not in this scaffold yet --- see offer-generator-market{.interpreted-text role="doc"}. Product overview: market-catalog-external-api{.interpreted-text role="doc"}, market-order-email-checkout{.interpreted-text role="doc"}.

Security notes

  • Never expose Mailoo API keys or base URLs via NEXT_PUBLIC_*.
  • Prefer same-origin BFF routes; the browser calls your Next.js app only.
  • Restricted API keys: use the scopes documented for each integration (e.g. blog.external-read, webhook.form-submission, chat.send-message, image.external-read, market.external-read, market.order.submit).
  • blog-nextjs-example{.interpreted-text role="doc"}
  • website-forms-nextjs-example{.interpreted-text role="doc"}
  • feedback-form-nextjs-example{.interpreted-text role="doc"}
  • lifecycle-events{.interpreted-text role="doc"}
  • chat-widget-nextjs-example{.interpreted-text role="doc"}
  • images{.interpreted-text role="doc"}
  • market-catalog-external-api{.interpreted-text role="doc"}
  • social-networks{.interpreted-text role="doc"}