Website Forms: Next.js Example
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.