Contact / feedback: Next.js Example
Contact / feedback: Next.js Example
This page shows how to integrate a Contact / feedback form webhook in a Next.js app using a BFF route: the browser posts to your app, which calls Mailoo with server-held credentials. No API key on the client.
This is not the same as a Form (newsletter) integration: feedback uses POST /api/v1/webhooks/feedback/... and a CONTACT_FORM integration. For subscription forms, see website-forms{.interpreted-text role="doc"} and website-forms-nextjs-example{.interpreted-text role="doc"}. For the raw API (headers, body, CORS), see contact-feedback-form{.interpreted-text role="doc"}.
Prerequisites
- A Mailoo project with a Contact / feedback form integration
- Allowed origins in the integration matching your site (when CORS is enforced)
- An API key with Feedback form submissions (
webhook.feedback-submission) if RESTRICTED, or Full Access
Environment (server-only)
Add to .env.local (or your deployment env). All four core variables are required for the BFF to accept requests.
MAILOO_FEEDBACK_INTEGRATION_API=https://api.mailoo.app
MAILOO_FEEDBACK_INTEGRATION_API_KEY=your-api-key-here
MAILOO_FEEDBACK_INTEGRATION_PROJECT_UID=your-project-uid-here
MAILOO_FEEDBACK_INTEGRATION_ID=your-contact-form-integration-id-here
Optional (merged into every submission's metadata on the server):
# MAILOO_FEEDBACK_INTEGRATION_FORM_NAMESPACE=production-site
# MAILOO_FEEDBACK_INTEGRATION_SITE_ID=my-brand
Optional allowlist: if set, the BFF rejects submissions whose metadata.formKey (or top-level formKey) is not in the list (comma-separated). The built-in bug report dialog sends formKey web-bug-report --- include that value if you enable the allowlist and use the default UI.
# MAILOO_FEEDBACK_ALLOWED_FORM_KEYS=web-bug-report,web-contact-page
Use server-side only; do not use NEXT_PUBLIC_* for API URL or key.
The integration ID must not be exposed via NEXT_PUBLIC_* either; the reference app passes MAILOO_FEEDBACK_INTEGRATION_ID from the server layout into the client only so draft storage can use a per-integration localStorage key (see Built-in bug report UI).
Multiple forms, one integration
You typically configure one feedback integration in env but may have several UIs (contact page, bug dialog, etc.). Distinguish them in the JSON body using metadata --- for example formKey (stable string per form) and entryPoint (e.g. footer_link, keyboard_shortcut). The Mailoo API stores this object on the message and feedback record; see contact-feedback-form{.interpreted-text role="doc"}.
BFF route handler
Create app/api/v1/webhooks/feedback/submit/route.ts. The handler should:
- Return 503 if any of the four required env vars is missing.
- Forward
email,message, optionalname/subject, and mergedmetadatato${MAILOO_FEEDBACK_INTEGRATION_API}/api/v1/webhooks/feedback/${projectUid}/${integrationId}withX-API-Keyand anOrigin(orReferer) header for CORS validation upstream.
Example (minimal; add logging as needed):
import { NextRequest } from 'next/server'
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
export async function POST(request: NextRequest) {
const baseUrl = process.env.MAILOO_FEEDBACK_INTEGRATION_API
const apiKey = process.env.MAILOO_FEEDBACK_INTEGRATION_API_KEY
const projectUid = process.env.MAILOO_FEEDBACK_INTEGRATION_PROJECT_UID
const integrationId = process.env.MAILOO_FEEDBACK_INTEGRATION_ID?.trim()
if (!baseUrl || !apiKey || !projectUid || !integrationId) {
return new Response(
JSON.stringify({
success: false,
message: 'Feedback integration not configured',
}),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
)
}
let body: Record<string, unknown>
try {
body = (await request.json()) as Record<string, unknown>
} catch {
return new Response(
JSON.stringify({ success: false, message: 'Invalid JSON body' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
const email = typeof body.email === 'string' ? body.email.trim() : ''
const message = typeof body.message === 'string' ? body.message.trim() : ''
if (!email || !message) {
return new Response(
JSON.stringify({
success: false,
message: 'Email and message are required',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
const clientMeta = isPlainObject(body.metadata) ? { ...body.metadata } : {}
const topLevelFormKey =
typeof body.formKey === 'string' ? body.formKey.trim() : ''
if (topLevelFormKey && clientMeta.formKey === undefined) {
clientMeta.formKey = topLevelFormKey
}
const namespace =
process.env.MAILOO_FEEDBACK_INTEGRATION_FORM_NAMESPACE?.trim()
if (namespace) clientMeta.siteFormNamespace = namespace
const siteId = process.env.MAILOO_FEEDBACK_INTEGRATION_SITE_ID?.trim()
if (siteId) clientMeta.siteId = siteId
const allowedRaw = process.env.MAILOO_FEEDBACK_ALLOWED_FORM_KEYS?.trim()
if (allowedRaw) {
const allowed = allowedRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const fk =
typeof clientMeta.formKey === 'string' ? clientMeta.formKey.trim() : ''
if (!fk || !allowed.includes(fk)) {
return new Response(
JSON.stringify({
success: false,
message: 'Invalid or disallowed formKey',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
}
const name =
typeof body.name === 'string' && body.name.trim()
? body.name.trim()
: undefined
const subject =
typeof body.subject === 'string' && body.subject.trim()
? body.subject.trim()
: undefined
const upstreamBody: Record<string, unknown> = {
email,
message,
metadata: clientMeta,
}
if (name) upstreamBody.name = name
if (subject) upstreamBody.subject = subject
const url = `${baseUrl}/api/v1/webhooks/feedback/${projectUid}/${integrationId}`
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
Origin:
request.headers.get('origin') || request.headers.get('referer') || '',
},
body: JSON.stringify(upstreamBody),
})
const data = await response.json()
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
})
} catch {
return new Response(
JSON.stringify({ success: false, message: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}
Client: POST to the BFF
From a client component, POST JSON to /api/v1/webhooks/feedback/submit with at least email and message. Include metadata.formKey (and any other context) so you can tell which form was used in the dashboard.
'use client'
async function submitFeedback() {
const res = await fetch('/api/v1/webhooks/feedback/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
message: 'The checkout button does nothing on mobile.',
subject: 'Bug report',
name: 'Jane',
metadata: {
formKey: 'web-contact-page',
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
},
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.message || 'Submit failed')
return data
}
Handle 503 (integration not configured) and 400 (validation or disallowed formKey) in your UI.
Built-in bug report UI
When all four required MAILOO_FEEDBACK_* variables are set, the locale layout wraps the main shell in a small provider that:
- Shows Report a problem in the footer (
apps/web/src/components/layout/footer.tsxviaReportBugFooterLink). - Registers ⌘⇧B (macOS) or Ctrl+Shift+B (Windows/Linux) to open the same modal, except when focus is in an editable field (input, textarea, select, or
contenteditable). - Submits with
metadataincludingformKey: 'web-bug-report',entryPoint(footer_linkorkeyboard_shortcut),pageUrl,pathname,locale,viewport, andtimeZone(plus any env-injectedsiteFormNamespace/siteIdfrom the BFF).
Draft persistence: While the modal is open, email, name, and message are written to localStorage after a short debounce (~350 ms). The storage key is mailoo_bug_report_draft_ followed by the integration ID with any character outside [a-zA-Z0-9_-] replaced by _. The entry is removed when all three fields are empty or after a successful submit. The integration ID is supplied only from the server layout so drafts stay separate per integration; it is not an API credential.
Strings for the dialog live under apps/web/messages/*/bugReport.json (locales en, es, ru, de).
Reference implementation (this repository)
In the Mailoo web app monorepo:
- BFF:
apps/web/src/app/api/v1/webhooks/feedback/submit/route.ts(usesgetMailooFeedbackIntegrationConfig()from the env helper; forwards viaapps/web/src/app/api/v1/lib/proxy-mailoo-webhook-post.ts) - Env helper:
apps/web/src/lib/mailoo-feedback-integration.ts - Gating and layout:
apps/web/src/app/[locale]/layout.tsxpassesfeedbackIntegrationIdintoBugReportGatewhen the integration is configured - Bug report UI:
apps/web/src/components/bug-report/(provider, modal, footer link)
Product ideas for the public API (attachments, rate limits, etc.) are collected in docs/feedback-webhook-api-improvements.md in the repository root.
See also
contact-feedback-form{.interpreted-text role="doc"} --- endpoint and JSON fieldswebsite-forms-nextjs-example{.interpreted-text role="doc"} --- Form (newsletter) BFF pattern../../api/authentication{.interpreted-text role="doc"} --- API keys andallowedOperations