Expose site chat from a Next.js host without putting an API key in the browser. Use @mailoo/chat for BFF routes and the optional floating widget. API contract and WebSocket notes: chat-widget{.interpreted-text role="doc"}, chat-websocket-production{.interpreted-text role="doc"}.
Flow
- The browser calls only same-origin routes on your Next.js app:
POST /api/v1/webhooks/chat/submit--- body{ "content": "...", "sessionPublicId?": "..." }GET /api/v1/webhooks/chat/messages?sessionPublicId=...--- optional polling (sincesupported)GET /api/v1/webhooks/chat/status--- optional integration status
- The Route Handler forwards to Mailoo with
X-API-Keyfrom server env (createChatSubmitHandler/createChatMessagesHandler/createChatStatusHandlerfrom@mailoo/chat/routes). Optionally injectsenderEmail/senderNameviaresolveSenderso the dashboard shows the visitor identity instead ofGuest. - Optional: the widget opens WebSocket to the API. The host is resolved at runtime on the server (no
NEXT_PUBLIC_*for API origin):
- Default order:
MAILOO_CHAT_WS_ORIGIN→MAILOO_CHAT_INTEGRATION_API→API_BASE_URL(getMailooChatWebSocketOriginfrom@mailoo/chat) - Set
MAILOO_CHAT_WS_ORIGINwhen the browser-visible API hostname differs from the URL the Node server uses to call the API
Package surface
// app/api/v1/webhooks/chat/submit/route.ts
import { createChatSubmitHandler } 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
import { createChatMessagesHandler } from '@mailoo/chat/routes'
export const GET = createChatMessagesHandler()
// app/api/v1/webhooks/chat/status/route.ts
import { createChatStatusHandler } from '@mailoo/chat/routes'
export const GET = createChatStatusHandler()
Headless session (custom UI) via @mailoo/chat/hooks:
import { useMailooChatSession } from '@mailoo/chat/hooks'
const chat = useMailooChatSession({
apiOrigin,
projectUid,
integrationId,
isAuthenticated,
enabled: panelOpen,
})
Environment variables (server-only)
MAILOO_CHAT_INTEGRATION_API=https://api.mailoo.app
MAILOO_CHAT_INTEGRATION_API_KEY=your-api-key-here
MAILOO_CHAT_INTEGRATION_PROJECT_UID=your-project-uid
MAILOO_CHAT_INTEGRATION_ID=your-jsbox-integration-id
MAILOO_CHAT_WELCOME_MESSAGE=Hello! How can we help?
# Optional public API origin for ws/wss when it differs from the server-side API URL
# MAILOO_CHAT_WS_ORIGIN=https://api.mailoo.app
The API key needs chat.send-message (or FULL). Copy projectUid and integrationId from the dashboard Connection & settings / embed snippet.
Floating widget
Import the host-independent shell CSS (required so the FAB stays fixed even when Tailwind does not scan @mailoo/chat). In this monorepo PostCSS resolves a relative path reliably:
@import '../../../../packages/chat/src/styles/widget.css';
/* published package: @import '@mailoo/chat/styles'; */
Recommended (default path): resolve host config on the server and mount MailooSiteChatShell. Brand, topics, position, welcome, privacy, and operators are always loaded from JSBOX config.chatWidget:
import { resolveMailooChatHostConfig } from '@mailoo/chat'
import { MailooSiteChatShell } from '@mailoo/chat/client'
const chat = await resolveMailooChatHostConfig({
locale,
isAuthenticated: Boolean(session?.user?.email),
})
{chat ? (
<MailooSiteChatShell
chat={chat}
unavailableAction={{
type: 'mailto',
email: 'support@example.com',
}}
/>
) : null}
resolveMailooChatHostConfig returns null when MAILOO_CHAT_* env is incomplete or widget-config cannot load (HTTP errors including 429, network failure, invalid payload). It never throws --- keep the page HTTP 200 and omit the chat shell. Call openMailooSiteChat() from any client component to open the panel.
Custom UI: pass widgetConfig into MailooSiteChatWidget yourself (from fetchMailooChatWidgetConfig or BFF GET …/webhooks/chat/widget-config). The launcher is a fixed FAB (corners or middle-left / middle-right) with Heroicons (not emoji); an online indicator appears when any public operator is online.
Chat unavailable / rate limit
The package classifies transport and service failures on submit/message poll (not raw API strings in the UI):
rate_limited--- HTTP 429 (API-key per-minute rate limit)unavailable--- network failure, BFF 502/**503**, or other 5xx
Validation errors, closed session (409), and missing session (404, cleared automatically) stay as ordinary inline errors --- they do not open the unavailable panel.
resolveMailooChatHostConfig returns null (do not mount chat) if widget-config cannot load. That keeps the host page up; the unavailable panel applies only after the widget is mounted, when send/poll fails.
Integrator chooses the action (contact email, support page, or custom UI):
<MailooSiteChatShell
chat={chat}
unavailableAction={{ type: 'mailto', email: 'support@example.com' }}
// or: { type: 'link', href: '/support', label: 'Contact support' }
// or: renderUnavailable={({ fault, labels, retry }) => ...}
/>
Without unavailableAction, visitors still see an explanation and Try again (retry clears the fault and resumes polling). Prefer passing a contact path so visitors are not stuck. Headless hosts can use useMailooChatSession fault / onFault / retryAfterFault.
mailoo.app dogfood: the product site mounts MailooSiteChatShell on every locale layout when MAILOO_CHAT_* is set, with unavailableAction → mailto:support@mailoo.app. The Support → Live Chat card opens the same controlled panel (no separate chat page). Without those env vars the card stays "Coming soon" and the launcher is omitted.
Dashboard chat console
For JSBOX integrations, Open chat console opens the operator UI for that integration. Operators use the dashboard session; visitors use the public webhook and WebSocket paths above.
Related
chat-widget{.interpreted-text role="doc"} --- public webhooks and scopeschat-websocket-production{.interpreted-text role="doc"} --- production WebSocket notesnextjs-packages{.interpreted-text role="doc"} --- package overview