Website Forms: Next.js Example

Last updated: Mar 4, 2026Section: General

Website Forms: Next.js Example

This page shows how to integrate a subscription form in a Next.js app using a BFF route: the form posts to your backend, which then calls the Mailoo API with server-held credentials. No API key on the client. For the API contract (required fields, CORS, errors), see website-forms{.interpreted-text role="doc"}.

Prerequisites

  • A Mailoo project with a Form integration and an API key (dashboard → project → API Keys). If the key is RESTRICTED, it must have form submission permission (webhook.form-submission).

Environment (server-only)

Add to .env.local:

MAILOO_CONTACT_FORM_INTEGRATION_API=https://api.mailoo.app
MAILOO_CONTACT_FORM_INTEGRATION_API_KEY=your-api-key-here
MAILOO_CONTACT_FORM_INTEGRATION_PROJECT_UID=your-project-uid-here
MAILOO_CONTACT_FORM_INTEGRATION_ID=your-form-integration-id-here

All four are required. Find the integration ID in the dashboard URL when viewing the form integration (e.g. /dashboard/projects/{projectUid}/integrations/{integrationId}). Use server-side only; do not use NEXT_PUBLIC_* for API URL or key.

BFF Route Handler

Create app/api/v1/webhooks/forms/submit/route.ts (or another path; the client will POST to this URL):

import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const baseUrl = process.env.MAILOO_CONTACT_FORM_INTEGRATION_API
  const apiKey = process.env.MAILOO_CONTACT_FORM_INTEGRATION_API_KEY
  const projectUid = process.env.MAILOO_CONTACT_FORM_INTEGRATION_PROJECT_UID
  const integrationId = process.env.MAILOO_CONTACT_FORM_INTEGRATION_ID?.trim()

  if (!baseUrl || !apiKey || !projectUid || !integrationId) {
    return new Response(
      JSON.stringify({
        success: false,
        message: 'Form integration not configured',
      }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    )
  }

  try {
    const body = await request.json()
    const url = `${baseUrl}/api/v1/webhooks/forms/${projectUid}/${integrationId}`

    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(body),
    })

    const data = await response.json()
    return new Response(JSON.stringify(data), {
      status: response.status,
      headers: { 'Content-Type': 'application/json' },
    })
  } catch (error) {
    return new Response(
      JSON.stringify({ success: false, message: 'Internal server error' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    )
  }
}

Client Form Component

The form posts to your BFF URL. Send the same payload your backend will forward to Mailoo: name, email, message (required), plus subject, source, metadata as needed. Example (client component):

'use client'

import { useState } from 'react'

export function NewsletterForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [loading, setLoading] = useState(false)
  const [status, setStatus] = useState<{
    type: 'success' | 'error'
    message: string
  } | null>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setStatus(null)
    if (!email.trim()) {
      setStatus({ type: 'error', message: 'Email is required' })
      return
    }
    setLoading(true)
    try {
      const res = await fetch('/api/v1/webhooks/forms/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: name.trim() || 'Newsletter Subscriber',
          email: email.trim(),
          subject: 'Newsletter Subscription',
          message: `Newsletter subscription from ${name.trim() || email.trim()}`,
          source: typeof window !== 'undefined' ? window.location.href : '',
          metadata: {
            type: 'newsletter_subscription',
            timestamp: new Date().toISOString(),
          },
        }),
      })
      const data = await res.json()
      if (res.ok && data.success) {
        setStatus({ type: 'success', message: 'Thank you for subscribing!' })
        setName('')
        setEmail('')
      } else {
        setStatus({
          type: 'error',
          message: data.message || 'Subscription failed',
        })
      }
    } catch {
      setStatus({
        type: 'error',
        message: 'Something went wrong. Please try again.',
      })
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-3">
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Your name (optional)"
        disabled={loading}
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
        disabled={loading}
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Subscribing...' : 'Subscribe'}
      </button>
      {status && (
        <p
          className={
            status.type === 'success' ? 'text-green-600' : 'text-red-600'
          }
        >
          {status.message}
        </p>
      )}
    </form>
  )
}

Reference Implementation

In this repository, the footer newsletter form (components/subscription/footer-newsletter.tsx) and the BFF route app/api/v1/webhooks/forms/submit/route.ts implement this pattern: the client never sees the API key or Mailoo URL.

📚