Contact / feedback: Next.js Example

Last updated: Apr 1, 2026Section: General

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:

  1. Return 503 if any of the four required env vars is missing.
  2. Forward email, message, optional name / subject, and merged metadata to ${MAILOO_FEEDBACK_INTEGRATION_API}/api/v1/webhooks/feedback/${projectUid}/${integrationId} with X-API-Key and an Origin (or Referer) 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.tsx via ReportBugFooterLink).
  • 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 metadata including formKey: 'web-bug-report', entryPoint (footer_link or keyboard_shortcut), pageUrl, pathname, locale, viewport, and timeZone (plus any env-injected siteFormNamespace / siteId from 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 (uses getMailooFeedbackIntegrationConfig() from the env helper; forwards via apps/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.tsx passes feedbackIntegrationId into BugReportGate when 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 fields
  • website-forms-nextjs-example{.interpreted-text role="doc"} --- Form (newsletter) BFF pattern
  • ../../api/authentication{.interpreted-text role="doc"} --- API keys and allowedOperations
📚