Next.js contact form

A form in the App Router can post to ezform with no client JavaScript at all: set the action to the endpoint and let the browser submit it. Nothing here needs a Route Handler, and the one shape to avoid is posting from the server, for the reason below.

A server component, no JavaScript

// No 'use client'. This renders to plain HTML and the browser does the posting.
export default function ContactPage() {
  const action = `https://api.ezform.io/f/${process.env.NEXT_PUBLIC_FORM_ID}`

  return (
    <form action={action} method="POST">
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />

      <div style={{ position: 'absolute', left: -9999 }} aria-hidden="true">
        <label htmlFor={process.env.NEXT_PUBLIC_HONEYPOT_FIELD}>Company website</label>
        <input
          id={process.env.NEXT_PUBLIC_HONEYPOT_FIELD}
          name={process.env.NEXT_PUBLIC_HONEYPOT_FIELD}
          type="text"
          tabIndex={-1}
          autoComplete="off"
        />
      </div>

      <button type="submit">Send</button>
    </form>
  )
}

The browser posts and navigates away. If the form has a redirect path configured in the console, ezform answers 303 and the browser follows it with a GET, so a refresh cannot repost it; without one, ezform renders its own short confirmation page.

Keeping the visitor on the page

Same request, sent with fetch, so you render the thank-you yourself. This is the only reason to make the form a client component.

'use client'

import { useState } from 'react'

export function ContactForm() {
  const [sent, setSent] = useState(false)
  const [failed, setFailed] = useState(false)

  async function onSubmit(event) {
    event.preventDefault()
    setFailed(false)

    const fields = Object.fromEntries(new FormData(event.currentTarget))

    try {
      const response = await fetch(`https://api.ezform.io/f/${process.env.NEXT_PUBLIC_FORM_ID}`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(fields),
      })

      // Every outcome the endpoint will talk about is a 2xx, spam included, so
      // there is nothing to branch on past the request having arrived. A
      // non-2xx here means it never reached the handler, which in a browser
      // means the origin is not on the allowlist.
      if (!response.ok) throw new Error(String(response.status))
      setSent(true)
    } catch {
      setFailed(true)
    }
  }

  if (sent) return <p>Thanks. We will be in touch.</p>

  return (
    <form onSubmit={onSubmit}>
      {/* the same fields and the same honeypot as above */}
      {failed && <p role="alert">That did not go through. Please try again.</p>}
    </form>
  )
}

Do not post it from a Server Action

A Server Action, a Route Handler, or any other fetch that runs on your server will not work by accident, and it is worth knowing why before reaching for one.

  • Name
    No Origin header
    Type
    the submission is discarded
    Description

    fetch in Node does not send one, and ezform discards a submission it cannot attribute to an allowed origin. You can set the header by hand, as the cURL example in Submitting a form does, but then the check is only proving that your server is your server.

  • Name
    A hop that can fail
    Type
    after the visitor was told it worked
    Description

    The endpoint exists so the lead is durable before anything else can go wrong. A submission sitting in your function’s memory while it waits on a network call is a lead a cold start, a timeout or a deploy can still lose.

Post from the browser. If you also want server-side validation, do it in the action that renders the form rather than proxying the submission through it.

Allow your origins

Scheme and host, no path, one entry each:

  • http://localhost:3000 for next dev, if you test against the real endpoint.
  • Your production host. The apex and www. are different origins.
  • A preview host, if you use one. On Vercel every deployment gets its own unique URL, so allowlisting the one you happen to be looking at covers that deployment and nothing pushed after it. Allowlist the stable branch alias instead, the URL that does not change when you push.

A submission from an origin that is not listed is discarded and never stored. It is the first thing to check when a form appears to do nothing: see Responses and failure.

Where to go next