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
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>
)
}
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>
)
}
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>
)
}
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"
}
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:
- Passes Turnstile + honeypot check
- Passes server-side validation rules
- Gets stored in your FormRoute dashboard (up to 30 days, configurable)
- 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>
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)