Codegea

Development

Custom software developmentSystems, platforms and integrations built around your operationCorporate websitesProfessional, fast and built to inspire trustOnline storesCatalog, payments and shipping ready to sell 24/7WooCommerceFast WooCommerce stores with each country’s payments and invoicingLanding pagesA page focused on one goal: turning visitors into customersMobile appsCustom iOS and Android apps that put your business in your customers’ pockets

Integration and CMS

CMS integrationsWordPress, Strapi, Directus and Arc XP connected to your whole ecosystemArc XPSupport, implementation and integrations for the media platformMigrationsMigrate your site, platform or database without losing anything

Growth and infrastructure

AI automationTechnology does the repetitive work and your team focuses on what mattersOn-page SEOBe found and recommended by Google: technical and content optimizationServers & hostingFast, secure servers monitored 24/7
RetainerYour dev team, by the monthFrom 20h a month. Fixed fee.
An AI quoting tool to quote services in minutesProfessional quotes in minutes, with projects, sellers and payments tracked in one place.See the case

By type

EcommerceStores and catalogsCorporate sitesBrand and lead genPlatforms & appsCustom software

Explore

All projectsCases and resultsClientsWho trusts usRetainerMonthly dev team

Blog

All articlesGuides and opinionNewsletterOne email a monthContactWe reply in 24h

Latest articles

How codegea.com is built: Next.js, Payload, Turso and R2CMS · 2026-10-20Cloudflare Workers: what they are and when to use themNode.js · 2026-10-19Live Preview in Payload with Next.jsCMS · 2026-10-19
NewsletterJoin our newsletterPractical web, SEO and AI notes.
ServicesCustom software developmentCorporate websitesOnline storesWooCommerceLanding pagesMobile appsCMS integrationsArc XPMigrationsAI automationOn-page SEOServers & hostingYour dev team, by the month
ProjectsProfessional quotes in minutes, with projects, sellers and payments tracked in one place.EcommerceCorporate sitesPlatforms & appsAll projectsClientsRetainer
Retainer
BlogAll articlesNewsletterContactHow codegea.com is built: Next.js, Payload, Turso and R2Cloudflare Workers: what they are and when to use themLive Preview in Payload with Next.jsJoin our newsletter
ESEN
Let's talk

Web

A contact form in Next.js with an API route and email delivery

How to receive a form's messages in Next.js with an API route instead of Server Actions: server-side validation, a honeypot against bots and email over SMTP.

Derian Pinto · Founder & Full Stack Developer2026-10-193 min

A contact form looks simple until the first spam message arrives, or the first message that doesn't. This is what we use on this site: a fetch from the browser, an API route that validates and saves, and email delivery over SMTP. No Server Actions.

Why an API route and not a Server Action

In the project we have a rule: every mutation or query from the client goes through an API route, and the client calls it with fetch. The reasons are practical:

  • An endpoint can be tested with curl, without going through the browser.
  • It is explicit: you see which URL receives what, and what it answers. It is easier to protect or limit.
  • The logic lives in lib/, outside the route and the component, and any other entry point can reuse it.

It isn't that Server Actions are bad. It is a team decision: we prefer a single way of doing things.

The route, minimal

In app/api/forms/route.ts:

import { submitForm } from '@/lib/forms';

export async function POST(req: Request) {
  let data: FormData;
  try {
    data = await req.formData();
  } catch {
    return Response.json({ status: 'error' }, { status: 400 });
  }
  const state = await submitForm(data);
  return Response.json(state, {
    status: state.status === 'error' ? (state.errors ? 422 : 500) : 200,
  });
}

It reads the FormData, calls a function and translates the result into an HTTP code: 422 if there are field errors, 500 if something on our side failed, 200 if all went well. The route knows nothing about the form.

The logic: validate on the server, not in the browser

In lib/forms.ts, submitForm does four things:

1. Drops bots with a honeypot. The form includes a hidden field, website. A person never sees or fills it; a bot that fills everything does. If it arrives with a value, we answer "success" without saving anything:

if (data.get('website')) return { status: 'success', message: copy.successFallback };

Answering success is on purpose: we don't give the bot a hint that it was detected.

2. Validates against the definition, not against what the browser sends. The form is defined in the admin (with Payload's Form Builder plugin). The server reloads that definition and checks each field against it:

const form = await getForm(Number(data.get('_form')), locale);

for (const field of form.fields ?? []) {
  const value = String(data.get(field.name) ?? '').trim();
  if (!value) {
    if ('required' in field && field.required) errors[field.name] = copy.required;
    continue;
  }
  if (field.blockType === 'email' && !EMAIL.test(value)) {
    errors[field.name] = copy.invalidEmail;
  } else if (value.length > MAX_LENGTH) {
    errors[field.name] = copy.required;
  }
  // …
}

So even if someone tampers with the HTML, the server applies the real rules. Fields that don't exist in the definition are ignored, and there is a length cap.

3. Saves the submission:

await payload.create({
  collection: 'form-submissions',
  data: { form: form.id, submissionData },
});

4. Returns a message. It uses the confirmation text the team wrote in the admin; if there is none, a default one from each language's dictionary.

The email

The email is sent by the form plugin when the submission is saved, to the address you configure. For it to really go out, Payload needs an email adapter:

email: process.env.SMTP_HOST
  ? nodemailerAdapter({
      defaultFromAddress: process.env.EMAIL_FROM ?? 'no-reply@codegea.com',
      defaultFromName: 'Codegea',
      transportOptions: {
        host: process.env.SMTP_HOST,
        port: Number(process.env.SMTP_PORT ?? 587),
        // …user and password
      },
    })
  : undefined,

With SMTP_HOST empty, Payload writes the email to the console, handy in development. Locally we use Mailpit, which receives emails in a web inbox without sending anything. In production, any SMTP provider; for example, Resend at smtp.resend.com. Recipients and sender come from environment variables, not from the code.

The client

The component only sends and shows the result:

const res = await fetch('/api/forms', { method: 'POST', body: new FormData(form) });
const next = (await res.json()) as FormState;
setState(next);
if (next.status === 'success') form.reset();

Per-field errors arrive in next.errors, and are shown next to each field, with the label visible. The response has the same shape in all three cases (success, field errors, general error), so the component is short.

What this version doesn't do

  • It doesn't rate-limit submissions. A honeypot stops simple bots, not a persistent attack. If spam reaches you, the next step is limiting by IP, either in your application or with Cloudflare rules like those in Cloudflare in front of your website.
  • It has no captcha. That is a decision: captchas cost conversions. We would add one only if the spam justified it.

Quick test

Without opening the browser:

curl -X POST http://localhost:4000/api/forms \
  -F "_form=1" -F "_locale=en" \
  -F "email=test@example.com" -F "message=Hello"

It should return JSON with status. And with -F "website=x" it should answer success without saving anything.

Share this article?LinkedInWhatsAppX

Written by

Derian Pinto

Founder & Full Stack Developer

Founder of Codegea. Builds custom websites, stores and platforms, and integrates and supports Arc XP for media companies.

Join our newsletter

An email with news and practical web, SEO and AI notes. No spam, unsubscribe anytime.

Shall we build your project?

Need help? Chat with us