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:

  1. Passa pela checagem do Turnstile + honeypot
  2. Passa pelas regras de validação do servidor
  3. É armazenado no dashboard do FormRoute (até 30 dias, configurável)
  4. 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