Website Forms: Next.js Example

Last updated: Oct 12, 2026Section: Integrations

Integrate a subscription form in Next.js with a BFF route: the browser posts to your app; your route calls Mailoo with server-held credentials. Prefer @mailoo/forms. For the API contract, see website-forms{.interpreted-text role="doc"}.

Prerequisites

  • A Mailoo project with a Form (FORM) integration
  • An API key with webhook.form-submission (or FULL)

Environment (server-only)

@mailoo/forms defaults to the env prefix MAILOO_CONTACT_FORM_INTEGRATION for FORM integrations. The name is historical: it is not the CONTACT_FORM (feedback) integration. Feedback uses MAILOO_FEEDBACK_INTEGRATION --- see feedback-form-nextjs-example{.interpreted-text role="doc"}.

Add to .env.local (or your host secrets):

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 for the default prefix. Do not use NEXT_PUBLIC_* for the API URL or key. For a second FORM integration, pass a custom prefix (or getConfig) to the route factory.

BFF route handler

// app/api/v1/webhooks/forms/submit/route.ts
import { createFormSubmitHandler } from '@mailoo/forms/routes'

export const POST = createFormSubmitHandler()

Client form

Post to your BFF. Body fields for FORM: email (required), optional name, subject, content, source, metadata. Use content (not message) if you want a custom inbox body.

With the package hook:

'use client'

import { useState } from 'react'
import { useMailooFormSubmit } from '@mailoo/forms/hooks'

export function NewsletterForm() {
  const { submit, pending } = useMailooFormSubmit({
    endpoint: '/api/v1/webhooks/forms/submit',
  })
  const [statusMessage, setStatusMessage] = useState('')

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setStatusMessage('')
    const fd = new FormData(e.currentTarget)
    const result = await submit({
      email: String(fd.get('email') || '').trim(),
      name: String(fd.get('name') || '').trim() || undefined,
      source: typeof window !== 'undefined' ? window.location.href : '',
    })
    if (!result.ok) {
      setStatusMessage(result.message)
      return
    }
    const data = result.data as { message?: string }
    setStatusMessage(
      typeof data.message === 'string' ? data.message : 'Subscribed'
    )
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="name" placeholder="Your name (optional)" disabled={pending} />
      <input name="email" type="email" required disabled={pending} />
      <button type="submit" disabled={pending}>
        {pending ? 'Subscribing…' : 'Subscribe'}
      </button>
      {statusMessage ? <p>{statusMessage}</p> : null}
    </form>
  )
}

Or call fetch yourself with the same JSON shape.

Lifecycle events

Anonymous newsletter submit is separate from identified-user registration. After you create the user in your app, emit from the server:

import { ingestMailooLifecycleEvent } from '@mailoo/forms/server'
import { MAILOO_LIFECYCLE_EVENTS } from '@mailoo/forms/events'

await ingestMailooLifecycleEvent({
  eventName: MAILOO_LIFECYCLE_EVENTS.USER_REGISTERED,
  idempotencyKey: `reg:${user.id}`,
  externalUserId: user.id,
  email: user.email,
  marketingConsent: { granted: true, source: 'signup' },
})

Pass your app user id as externalUserId (not Mailoo contactId). For browser → BFF, use useMailooLifecycleEvent({ getHeaders }) so the host can attach Authorization. Bulk admin status: useMailooLifecycleStatus → ?externalUserIds=. Retry: POST .../events/{eventId}/retry via useMailooLifecycleRetry.

Default env prefix: MAILOO_LIFECYCLE_*. Full contract: lifecycle-events{.interpreted-text role="doc"}.

  • website-forms{.interpreted-text role="doc"} --- endpoint and subscriber behaviour
  • lifecycle-events{.interpreted-text role="doc"} --- user.registered / user.activated
  • feedback-form-nextjs-example{.interpreted-text role="doc"} --- CONTACT_FORM / feedback
  • nextjs-packages{.interpreted-text role="doc"} --- package overview