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 andLink- 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 beforeres.okis checked and can 500 the whole response. - Do not call Mailoo from root
layout.tsxunless you use a never-throw helper (resolveMailooChatHostConfigis 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_*_APImust be the public API origin (e.g.https://api.mailoo.app), notlocalhost.
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_IDproxyMailooWebhookJsonPost/proxyMailooApiGet--- POST/GET JSON withX-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 importnext/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 usenext: { revalidate: 60 }by default; pass{ revalidate: 0 }(or another number) as the last argument. Mark writes always POST without ISR cache.listPostsreturnsdata(posts array) pluspaginationandimagePublicEmbedBaseUrl(same envelope as the external list API; incomplete envelope →ok: false). Slug detail includeslikeCount/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). Omitprefixon 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 byingestMailooLifecycleEventand lifecycle BFF handlers. - Multiple integrations: pass
prefix(orgetConfig) 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 catalogMAILOO_LIFECYCLE_EVENTS(user.registered,user.activated) and attribute-key validators@mailoo/forms/server---ingestMailooLifecycleEventfor 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).
Related pages
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"}