DEV Community

Eduardo Villão
Eduardo Villão

Posted on

How to add a contact form to a Next.js app without a backend

If you've ever built a Next.js app and needed a contact form, you've probably hit the same wall: you don't want to set up a backend just to receive an email.

You have a few options. You could write an API route, wire up an email service like Resend or SendGrid, handle validation, add spam protection, deploy, and maintain it forever. Or you could use a form backend service and skip all of that.

This tutorial shows the second path. We'll build a working contact form in Next.js — App Router — that validates fields, blocks spam, and delivers submissions to your inbox. No backend, no server to maintain.

We'll use FormRoute as the form backend. Free tier covers 1,000 submissions a month with a dashboard included.

What we're building

A contact form with:

  • Name, email, and message fields
  • Client-side and server-side validation
  • Spam protection with Cloudflare Turnstile
  • Success and error states
  • Zero backend code

1. Create your FormRoute endpoint

Go to formroute.dev and create a free account. After signup, create a new form and copy your endpoint URL. It looks like this:

https://api.formroute.dev/f/YOUR_KEY
Enter fullscreen mode Exit fullscreen mode

That's the only setup you need on the FormRoute side.

2. Add Turnstile to your page

FormRoute uses Cloudflare Turnstile for spam protection. It's invisible to real users — no "click the traffic lights" — and it's handled automatically when you add two lines to your page.

Add the Turnstile script to your layout.tsx:

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://challenges.cloudflare.com/turnstile/v0/api.js"
          strategy="lazyOnload"
        />
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

3. Build the form component

Create a new component app/components/ContactForm.tsx:

'use client'

import { useState, useRef } from 'react'

export default function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
  const [errors, setErrors] = useState<Record<string, string>>({})
  const formRef = useRef<HTMLFormElement>(null)

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()

    const formData = new FormData(e.currentTarget)
    const data = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      message: formData.get('message') as string,
      'cf-turnstile-response': formData.get('cf-turnstile-response') as string,
    }

    // Basic client-side validation
    const newErrors: Record<string, string> = {}
    if (!data.name || data.name.length < 2) newErrors.name = 'Name is required'
    if (!data.email || !data.email.includes('@')) newErrors.email = 'Valid email is required'
    if (!data.message || data.message.length < 10) newErrors.message = 'Message must be at least 10 characters'

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }

    setStatus('loading')
    setErrors({})

    try {
      const res = await fetch('https://api.formroute.dev/f/YOUR_KEY', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })

      if (!res.ok) throw new Error('Submission failed')

      setStatus('success')
      formRef.current?.reset()
    } catch {
      setStatus('error')
    }
  }

  if (status === 'success') {
    return (
      <div>
        <h3>Message sent.</h3>
        <p>We'll get back to you as soon as possible.</p>
      </div>
    )
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
        />
        {errors.name && <span>{errors.name}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
        />
        {errors.email && <span>{errors.email}</span>}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          rows={5}
          required
        />
        {errors.message && <span>{errors.message}</span>}
      </div>

      {/* Turnstile widget — invisible to most users */}
      <div
        className="cf-turnstile"
        data-sitekey="YOUR_FORMROUTE_TURNSTILE_KEY"
      />

      {/* Honeypot — do not remove */}
      <input
        type="text"
        name="_honeypot"
        style={{ display: 'none' }}
        tabIndex={-1}
        autoComplete="off"
      />

      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending...' : 'Send message'}
      </button>

      {status === 'error' && (
        <p>Something went wrong. Please try again.</p>
      )}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

4. Use the component

Add the form to any page:

// app/contact/page.tsx
import ContactForm from '@/components/ContactForm'

export default function ContactPage() {
  return (
    <main>
      <h1>Contact</h1>
      <ContactForm />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

5. How server-side validation works

Even though we validate on the client, FormRoute also validates every submission on the server — on the edge, before anything is stored or forwarded.

You configure validation rules once in your FormRoute dashboard:

{
  "email": "required|email",
  "name": "required|min:2",
  "message": "required|min:10"
}
Enter fullscreen mode Exit fullscreen mode

This means even if someone bypasses your frontend and POSTs directly to your endpoint, garbage doesn't reach your inbox. Both layers run independently.

6. What happens after submission

Every valid submission:

  1. Passes Turnstile + honeypot check
  2. Passes server-side validation rules
  3. Gets stored in your FormRoute dashboard (up to 30 days, configurable)
  4. Triggers an email notification to your inbox

You can review, search, and export submissions from the dashboard at any time. If an email notification falls into spam, your submissions are still there.

What you skipped

By using FormRoute instead of a custom API route, you skipped:

  • Setting up an email service (Resend, SendGrid, SES)
  • Writing validation logic server-side
  • Handling spam protection infrastructure
  • Deploying and maintaining an API endpoint
  • Debugging email deliverability

The form works. You ship the feature and move on.

Plain HTML version

If you're not using React, the same result in plain HTML:

<form action="https://api.formroute.dev/f/YOUR_KEY" method="POST">
  <input type="text" name="name" required />
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>

  <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_KEY"></div>

  <input type="text" name="_honeypot" style="display:none" tabindex="-1" />

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

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async></script>
Enter fullscreen mode Exit fullscreen mode

Wrap up

Contact forms are one of those features that look simple but hide complexity. Validation, spam, deliverability, storage — each one is a small problem that adds up.

A form backend handles all of it so you don't have to. The tradeoff is a dependency on a third-party service — but for most projects, that's a good tradeoff.

FormRoute free tier covers 1,000 submissions a month, includes a dashboard, and has no time limit. formroute.dev

Top comments (0)