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.
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
@mailoo/chat JSBOX visitor chat BFF, headless hooks, and floating widget (WebSocket to Mailoo API)
@mailoo/images Public image proxy route factory + rewriteMailooPublicImageUrls
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 - Fail-fast config validation (missing env → HTTP 503)
- 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
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/next-core
import {
readMailooIntegrationConfig,
proxyMailooWebhookJsonPost,
getRequestOriginBaseUrl,
jsonError,
} from '@mailoo/next-core'
readMailooIntegrationConfig({ prefix })--- reads{PREFIX}_API,_API_KEY,_PROJECT_UID,_ID/_INTEGRATION_IDproxyMailooWebhookJsonPost--- POST JSON withX-API-Keyand browserOrigin/Refererfor upstream CORSgetRequestOriginBaseUrl--- absolute origin from request headers (RSC → BFF)
@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, or createMailooBlogRoutes() (list, slug, jsonLd, categories, categoryDescription).
Server client / RSC
import {
createMailooBlogClient,
getMailooBlogConfigFromEnv,
fetchBlogPostBySlug,
firstEmbedImageUrlFromArticle,
} from '@mailoo/blog/server'
createMailooBlogClient(config)---listPosts,getPostBySlug,getPostJsonLd,listCategories,getCategoryDescription(direct Mailoo API; use from RSC / sitemap so K8s/Docker never loops back to the host BFF). Fetches usenext: { revalidate: 60 }by default; pass{ revalidate: 0 }(or another number) as the last argument.listPostsreturnsdata(posts array) pluspaginationandimagePublicEmbedBaseUrl(same envelope as the external list API; incomplete envelope →ok: false).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'
Or the barrel @mailoo/blog/client. Prefer subpaths when you want to avoid pulling optional peers (e.g. react-markdown) into every import.
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 - 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.
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"} and feedback-form-nextjs-example{.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.
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=',
})
Bearer dashboard-style BFF: createImagesLibraryHandlers({ getApiBaseUrl, getAuthHeader, resolveAuthHeader }). Details: images{.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).
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"}chat-widget-nextjs-example{.interpreted-text role="doc"}images{.interpreted-text role="doc"}