Como adicionar um formulário de contato em um app Next.js sem backend
Se você já construiu um app Next.js e precisou de um formulário de contato, provavelmente bateu na mesma parede: você não quer montar um backend só pra receber um e-mail.
Você tem algumas opções. Pode escrever uma API route, plugar um serviço de e-mail como Resend ou SendGrid, tratar validação, adicionar proteção contra spam, fazer deploy e manter isso pra sempre. Ou pode usar um serviço de backend de formulário e pular tudo isso.
Este tutorial mostra o segundo caminho. Vamos montar um formulário de contato funcional no Next.js — App Router — que valida campos, bloqueia spam e entrega os envios na sua caixa de entrada. Sem backend, sem servidor pra manter.
Vamos usar o FormRoute como backend do formulário. O plano gratuito cobre 1.000 envios por mês, com dashboard incluído.
O que vamos construir
Um formulário de contato com:
- Campos de nome, e-mail e mensagem
- Validação no client e no servidor
- Proteção contra spam com Cloudflare Turnstile
- Estados de sucesso e erro
- Zero código de backend
1. Crie seu endpoint no FormRoute
Vá em formroute.dev e crie uma conta gratuita. Depois do cadastro, crie um formulário novo e copie a URL do endpoint. Ela é assim:
https://api.formroute.dev/f/YOUR_KEY
Essa é toda a configuração necessária do lado do FormRoute.
2. Adicione o Turnstile na sua página
O FormRoute usa o Cloudflare Turnstile pra proteção contra spam. Ele é invisível pra usuário real — nada de “clique nos semáforos” — e é tratado automaticamente quando você adiciona duas linhas na sua página.
Adicione o script do Turnstile no seu layout.tsx:
// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR">
<body>
{children}
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="lazyOnload"
/>
</body>
</html>
)
}
3. Construa o componente do formulário
Crie um novo componente 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,
}
// Validação básica no client
const newErrors: Record<string, string> = {}
if (!data.name || data.name.length < 2) newErrors.name = 'Nome é obrigatório'
if (!data.email || !data.email.includes('@')) newErrors.email = 'E-mail válido é obrigatório'
if (!data.message || data.message.length < 10) newErrors.message = 'A mensagem precisa ter pelo menos 10 caracteres'
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>Mensagem enviada.</h3>
<p>Retornamos assim que possível.</p>
</div>
)
}
return (
<form ref={formRef} onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Nome</label>
<input
id="name"
name="name"
type="text"
required
/>
{errors.name && <span>{errors.name}</span>}
</div>
<div>
<label htmlFor="email">E-mail</label>
<input
id="email"
name="email"
type="email"
required
/>
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<label htmlFor="message">Mensagem</label>
<textarea
id="message"
name="message"
rows={5}
required
/>
{errors.message && <span>{errors.message}</span>}
</div>
{/* Widget do Turnstile — invisível pra maioria dos usuários */}
<div
className="cf-turnstile"
data-sitekey="YOUR_FORMROUTE_TURNSTILE_KEY"
/>
{/* Honeypot — não remova */}
<input
type="text"
name="_honeypot"
style={{ display: 'none' }}
tabIndex={-1}
autoComplete="off"
/>
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Enviando...' : 'Enviar mensagem'}
</button>
{status === 'error' && (
<p>Algo deu errado. Tente novamente.</p>
)}
</form>
)
}
4. Use o componente
Adicione o formulário em qualquer página:
// app/contact/page.tsx
import ContactForm from '@/components/ContactForm'
export default function ContactPage() {
return (
<main>
<h1>Contato</h1>
<ContactForm />
</main>
)
}
5. Como funciona a validação no servidor
Mesmo validando no client, o FormRoute também valida cada envio no servidor — na edge, antes de qualquer coisa ser armazenada ou encaminhada.
Você configura as regras de validação uma vez no dashboard do FormRoute:
{
"email": "required|email",
"name": "required|min:2",
"message": "required|min:10"
}
Isso significa que mesmo se alguém contornar seu frontend e mandar um POST direto pro endpoint, lixo não chega na sua caixa de entrada. As duas camadas rodam de forma independente.
6. O que acontece depois do envio
Todo envio válido:
- Passa pela checagem do Turnstile + honeypot
- Passa pelas regras de validação do servidor
- É armazenado no dashboard do FormRoute (até 30 dias, configurável)
- Dispara uma notificação por e-mail pra sua caixa de entrada
Você pode revisar, buscar e exportar os envios pelo dashboard a qualquer momento. Se a notificação por e-mail cair no spam, seus envios continuam lá.
O que você pulou
Ao usar o FormRoute em vez de uma API route própria, você pulou:
- Configurar um serviço de e-mail (Resend, SendGrid, SES)
- Escrever a lógica de validação no servidor
- Cuidar da infraestrutura de proteção contra spam
- Fazer deploy e manter um endpoint de API
- Debugar entregabilidade de e-mail
O formulário funciona. Você entrega a feature e segue em frente.
Versão em HTML puro
Se você não usa React, o mesmo resultado em HTML puro:
<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">Enviar</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async></script>
Fechando
Formulário de contato é uma daquelas features que parecem simples mas escondem complexidade. Validação, spam, entregabilidade, armazenamento — cada uma é um probleminha que vai somando.
Um backend de formulário cuida de tudo isso pra você não ter que cuidar. O trade-off é a dependência de um serviço de terceiro — mas pra maioria dos projetos, é um bom trade-off.
O plano gratuito do FormRoute cobre 1.000 envios por mês, inclui dashboard e não tem prazo de validade. formroute.dev