Guias16 min de leitura
Adicione um formulário de contato a um site estático sem backend
Coloque um formulário de contato funcionando num site estático sem servidor: para onde vai o POST, spam, respostas por e-mail e código para todo host e framework.
Um host estático serve arquivos e recusa um POST de formulário, geralmente com 405 ou 404, então o formulário precisa enviar para outro lugar. Aponte o action do formulário para um backend de formulários como o Ikiform, que armazena cada mensagem, filtra spam e manda um e-mail para você com o remetente como endereço de reply-to. Seu site continua estático: sem código de servidor, sem chaves secretas na página e sem mudança na forma como você faz deploy.
Um site estático é uma pasta de HTML, CSS e JavaScript que um host entrega como arquivos. É por isso que ele é rápido, barato e difícil de quebrar. E também é por isso que o formulário de contato é a primeira coisa que para de funcionar: um envio de formulário é uma requisição que alguém precisa receber, e um host estático não tem nada escutando.
Por que o formulário falha num host estático
Um <form method="post"> sem action envia para a própria página em que está. Num host estático, isso é um arquivo, e arquivos só respondem a GET e HEAD. Enviamos um POST para uma página estática publicada em cada um dos principais hosts em 2026-10-25. Nenhum deles armazenou nada:
| Host | O que um POST para uma página estática recebe |
|---|---|
| GitHub Pages | 405 Not Allowed, como uma pequena página HTML |
| Vercel | 405 com corpo vazio |
| Netlify | 404, com a página 404 do site se houver uma, a menos que o Netlify Forms tenha detectado aquele formulário no deploy |
| Cloudflare Pages | 405 com corpo vazio |
| Endpoint de site do Amazon S3 | 405 Method Not Allowed, código MethodNotAllowed |
A solução é a mesma em todo lugar: enviar o post para uma URL que o aceite. Essa URL pode ser um código que você escreve e hospeda, um recurso do seu host ou um backend de formulários. O resto deste guia usa um backend de formulários, porque ele mantém o site estático.
- Sem action, ou com um action que aponta para um caminho no seu próprio site, o navegador envia para o host estático.
- O host não tem código para rodar num POST, então responde 405 ou 404 e a mensagem se perde.
- Com o action apontando para o seu endpoint do Ikiform, o envio vai para um servidor que o aceita.
- O Ikiform verifica se a mensagem é spam, armazena e manda um e-mail para você.
- O Ikiform responde com um redirecionamento 303, e o navegador carrega a página de agradecimento no seu próprio site.
O que acontece com cada mensagem
O Ikiform é o endpoint para o qual o formulário envia. Ele não hospeda sua página nem renderiza seu formulário; os dois continuam com você. Para cada envio, ele roda as verificações de spam, armazena a mensagem e a distribui para os lugares onde você lê.
- Um visitante preenche o formulário de contato, e ele é enviado ao seu endpoint do Ikiform.
- O Ikiform confere o honeypot, o limite de taxa e a pontuação de spam, e então armazena a mensagem.
- Cada destinatário das notificações recebe um e-mail, com o reply-to definido como o endereço do remetente.
- A mensagem aparece no painel, onde você pode pesquisá-la, recuperar spam e exportar CSV.
- Se você adicionar um webhook, o Ikiform envia uma cópia assinada para o Slack, o Discord, uma ferramenta de automação ou o seu próprio código.
Cinco formas de receber o formulário de um site estático
| Abordagem | Bom em | Contras |
|---|---|---|
Um link mailto: ou action="mailto:..." | Zero configuração. Serve para uma página pessoal com pouco tráfego. | Abre o app de e-mail do visitante, se houver um configurado. Nada é armazenado nem filtrado, e seu endereço fica no HTML para os scrapers. |
| O recurso de formulários do seu host, como o Netlify Forms | Um atributo, nenhuma outra conta, e geralmente incluído no plano de hospedagem. | Só funciona naquele host. Mudar de host significa refazer o formulário, e formulários renderizados por JavaScript precisam de gambiarras. |
| Uma função serverless escrita por você | Controle total sobre a validação, o armazenamento e o e-mail que você envia. | Agora você é dono de um backend: uma chave de API de e-mail, filtro de spam, limites de taxa, novas tentativas e logs. O formato de função de cada host é diferente. |
| Um formulário hospedado incorporado, como Google Forms, Tally ou Typeform | Sem código. Bom para formulários longos ou com várias etapas. | Um iframe ou um link para outro site, com a aparência e os scripts deles na sua página. |
| Um backend de formulários, como o Ikiform, o Formspree, o Basin ou o Web3Forms | Seu próprio HTML em qualquer host. Armazenamento, filtro de spam e e-mail sem servidor. | Mais uma conta e mais uma fatura, e os dados do formulário ficam com esse serviço. |
Se você está na Netlify e nunca pretende sair, o Netlify Forms é imbatível em esforço. Se você quer escrever o backend, uma função é um bom projeto de fim de semana. Se você quer que o formulário continue funcionando quando trocar de host ou de framework, use um backend de formulários. As comparações colocam os backends lado a lado.
O formulário
Todos os exemplos abaixo enviam a partir do navegador, porque um deploy estático não tem servidor de onde enviar. Escolha sua stack; a escolha vale para todos os exemplos de código destes guias. Geradores de sites estáticos têm páginas de configuração próprias: Jekyll, Hugo, Eleventy e Gatsby.
Esta é a integração inteira para um site escrito à mão. O formulário envia do jeito tradicional, então funciona com o JavaScript desligado, e o navegador cai no seu thanks.html depois de um envio bem-sucedido.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" maxlength="120" required />
<p class="field-error" data-error-for="name" hidden></p>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="field-error" data-error-for="email" hidden></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
<p class="field-error" data-error-for="message" hidden></p>
<input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button type="submit">Send message</button>
<p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>
<script src="/contact.js" defer></script>O script é opcional. Ele mantém o visitante na página, coloca cada erro de campo abaixo do input correspondente e deixa a mensagem digitada no lugar quando algo falha.
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");
function showErrors(fields = {}) {
for (const slot of form.querySelectorAll("[data-error-for]")) {
const name = slot.dataset.errorFor;
const message = fields[name]?.[0] ?? "";
slot.textContent = message;
slot.hidden = !message;
form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.hidden = true;
done.hidden = false;
return;
}
showErrors(result.error.fields);
status.textContent = result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});Com output: "export", o next build grava arquivos simples em out/. Server Actions e Route Handlers que leem a requisição não são suportados num export estático, então o formulário envia a partir do navegador.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [notice, setNotice] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setNotice("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
const fields = result.error.fields ?? {};
setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
setNotice(result.error.message);
} catch {
setNotice("Couldn't send. Check your connection and try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
{errors.name ? <p>{errors.name}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
{errors.message ? <p>{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{notice ? <p role="alert">{notice}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Renderize <ContactForm /> a partir de app/contact/page.tsx. O formulário mantém o action, então ainda envia antes de o bundle JavaScript carregar.
O Astro pré-renderiza todas as páginas por padrão, que é o que um host estático quer. Não use Astro Actions aqui: enviar um formulário para uma action exige renderização sob demanda e um adapter. Envie ao Ikiform e deixe o <script> incluído manter os visitantes na página.
---
import Layout from "../layouts/Layout.astro";
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---
<Layout title="Contact">
<form id="contact" action={endpoint} method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required></textarea>
<input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<output id="contact-status" role="status"></output>
</form>
</Layout>
<script>
const form = document.querySelector<HTMLFormElement>("#contact")!;
const status = document.querySelector<HTMLOutputElement>("#contact-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.value = "Sending";
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) {
form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Thanks. Your message is on its way, and I'll reply by email." }));
return;
}
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.entries(fields)[0];
status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
});
</script>Astro.site vem de site no astro.config.mjs. Defina-o com o seu endereço https: para que o valor de _redirect seja uma URL completa que o Ikiform consiga seguir.
O modo SPA gera um shell renderizado no cliente que qualquer host estático consegue servir. Server functions ainda precisam de um servidor, então um deploy estático não consegue rodá-las; a rota envia a partir do navegador e vai para /thanks em caso de sucesso.
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackStart({
spa: {
enabled: true,
},
}),
viteReact(),
],
});import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const navigate = useNavigate();
const [problems, setProblems] = useState<string[]>([]);
const [sending, setSending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSending(true);
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget),
});
const result = await response.json();
if (result.ok) {
await navigate({ to: "/thanks" });
return;
}
const fields: Record<string, string[]> = result.error.fields ?? {};
setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
} catch {
setProblems(["Couldn't send. Check your connection and try again."]);
}
setSending(false);
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
{problems.length ? (
<ul role="alert">
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : null}
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Com ssr: false não existe build de servidor, então uma rota não pode ter uma action de servidor. Uma clientAction roda no navegador no lugar: ela encaminha o envio do <Form> ao Ikiform e redireciona em caso de sucesso.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export async function clientAction({ request }: Route.ClientActionArgs) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: await request.formData(),
});
const result = await response.json();
if (result.ok) return redirect("/thanks");
return {
fields: (result.error.fields ?? {}) as Record<string, string[]>,
message: String(result.error.message),
};
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
const error = (name: string) => actionData?.fields[name]?.[0];
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{error("name") ? <p>{error("name")}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{error("email") ? <p>{error("email")}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
{error("message") ? <p>{error("message")}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</Form>
);
}Com adapter-static e prerender = true, toda página é um arquivo. Form actions do SvelteKit ficam de fora: páginas com actions não podem ser pré-renderizadas, porque um servidor precisa tratar o POST delas. O componente envia com fetch.
export const prerender = true;<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let phase = $state<"idle" | "sending" | "sent">("idle");
let errors = $state<Record<string, string[]>>({});
let notice = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
phase = "sending";
notice = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
phase = "sent";
return;
}
errors = result.error.fields ?? {};
notice = result.error.message;
} catch {
notice = "Couldn't send. Check your connection and try again.";
}
phase = "idle";
}
</script>
{#if phase === "sent"}
<p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
<form action={ENDPOINT} method="post" onsubmit={send}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
{#if errors.name}<p>{errors.name[0]}</p>{/if}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
{#if errors.email}<p>{errors.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
{#if errors.message}<p>{errors.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if notice}<p role="alert">{notice}</p>{/if}
<button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
</form>
{/if}nuxt generate grava as páginas pré-renderizadas em .output/public. Rotas em server/api não existem num host estático, então o componente chama o Ikiform direto com $fetch e vai para /thanks em caso de sucesso.
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
notice.value = "";
try {
await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
await navigateTo("/thanks");
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
errors.value = data?.error?.fields ?? {};
notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="form.name" name="name" autocomplete="name" required />
<p v-if="errors.name">{{ errors.name[0] }}</p>
<label for="email">Email</label>
<input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
<p v-if="errors.message">{{ errors.message[0] }}</p>
<input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="notice" role="alert">{{ notice }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>Uma single-page app com Vite num host estático. O endpoint fica numa variável VITE_ para que os builds de preview e de produção possam apontar para formulários diferentes; ele acaba no bundle de qualquer jeito, o que não é problema, porque não é segredo.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { type FormEvent, useState } from "react";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ kind: "idle" });
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus({ kind: "sending" });
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) return setStatus({ kind: "sent" });
const fields: Record<string, string[]> = result.error.fields ?? {};
const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
} catch {
setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
}
}
if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{status.kind === "failed" ? (
<div role="alert">
{status.lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
</form>
);
}O mesmo envio pelo navegador num single-file component do Vue, lendo o endpoint da mesma variável VITE_. Só o primeiro erro aparece, ao lado do botão.
<script setup lang="ts">
import { computed, ref } from "vue";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);
async function send(event: SubmitEvent) {
sending.value = true;
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
sent.value = result.ok;
fields.value = result.ok ? {} : (result.error.fields ?? {});
notice.value = result.ok ? "" : result.error.message;
} catch {
notice.value = "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="firstError" role="alert">{{ firstError }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Troque
frm_xxxxxxxxxxxxxxxxpelo id do seu formulário, que está no painel. O id é público por design: ele está no seu HTML assim que você faz o deploy. Nunca coloque uma chave de API secreta num site estático. - Mantenha o nome do campo de e-mail como
email. O Ikiform o define como endereço de reply-to dos e-mails de notificação e o confere contra domínios de e-mail descartáveis. - O input oculto
_gotchaé o honeypot. As pessoas nunca o veem; bots que preenchem todos os inputs se marcam como spam. - As versões em JavaScript enviam
Accept: application/json, então o Ikiform responde201em caso de sucesso, ou422com um objetoerror.fieldsque aponta cada campo e o problema.
Se o seu framework roda num servidor em produção, você pode enviar a partir de lá e manter o endpoint fora da página. Os guias de Next.js, SvelteKit e Nuxt mostram as versões no servidor, e A partir de um servidor explica as chaves secretas.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Campos para um formulário de contato
Nome, e-mail e mensagem cobrem quase todo formulário de contato. Um select de assunto vale a pena quando pessoas diferentes respondem tipos diferentes de mensagem. Experimente o formulário abaixo: ele roda o validador do Ikiform no seu navegador e mostra o que seria armazenado, ou o 422 que você receberia de volta.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
| Campo | Por quê | Lembre-se |
|---|---|---|
name | Como você cumprimenta a pessoa na resposta | Um link num campo de nome é um forte sinal de spam, então mantenha o id name |
email | Para onde vai a resposta | Obrigatório. type="email" abre o teclado certo no celular |
topic | Mande vendas e suporte para a pessoa certa | Um select só aceita os valores listados, então é seguro filtrar por ele |
message | A razão de ser do formulário | Um tamanho mínimo de 10 impede envios de uma palavra só |
Deixe de fora telefone, tamanho da empresa e qualquer outra coisa que você não vá usar na primeira resposta. Cada campo que você adiciona custa mensagens. Sem uma definição de campos, o Ikiform armazena quaisquer inputs com nome que o formulário enviar; com uma, ele rejeita envios que não correspondem.
422 files_not_supported.Responda pela sua caixa de entrada
Os e-mails de notificação vão para até 10 destinatários confirmados por formulário. Quando o formulário tem um campo email, o reply-to do e-mail é o remetente, então o Reply no seu app de e-mail responde direto para a pessoa. Você nunca precisa copiar um endereço do painel.
As notificações têm limite de 50 e-mails por formulário por hora, e depois um resumo por hora, para que uma onda de spam não soterre sua caixa de entrada. Mensagens marcadas como spam não enviam e-mail nenhum. _subject não muda o assunto dos e-mails de notificação. Ele é repassado aos webhooks e à API como meta.subject.
Quer as mensagens num chat da equipe em vez do e-mail, ou além dele? Adicione um webhook e siga o guia do Slack, do Discord ou do Telegram.
O que o visitante vê depois de enviar
Um envio de HTML simples termina num redirecionamento. O Ikiform responde a um envio bem-sucedido pelo navegador com 303 See Other, e o navegador carrega o destino com um GET, que o seu host estático serve sem problemas.
- O navegador envia os campos do formulário ao seu endpoint do Ikiform.
- O Ikiform armazena a mensagem e responde 303 See Other com um header Location.
- O navegador segue o redirecionamento com um GET para a sua página de agradecimento, que o host estático serve como qualquer outro arquivo.
O Ikiform escolhe o destino nesta ordem:
- Redirect after submit nas configurações do formulário. Qualquer URL
http:ouhttps:funciona, porque só a sua equipe pode defini-la. É a escolha mais simples para um único site. - Um campo
_redirectno formulário, se for uma URLhttps:em um dos Allowed domains do formulário. Use-o quando um formulário atende vários sites ou idiomas. - Caso contrário, a página de agradecimento hospedada pelo Ikiform, que leva de volta ao seu site quando o envio veio de uma página
https:permitida.
Faça da página de agradecimento um arquivo de verdade e mantenha-a fora dos resultados de busca. Diga quando você vai responder; isso poupa um segundo e-mail da mesma pessoa.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Message sent</title>
</head>
<body>
<h1>Thanks, your message is on its way</h1>
<p>I read every message and reply within two working days, from the address on this site.</p>
<p><a href="/">Back to the home page</a></p>
</body>
</html>As versões em JavaScript acima pulam o redirecionamento. Um envio com Accept: application/json recebe JSON de volta, e a própria página mostra a frase de sucesso.
Spam num formulário estático
Seu endpoint está no código-fonte da página, e bots leem código-fonte. Espere spam poucos dias depois de entrar no ar. O Ikiform empilha as defesas para você poder começar sem configurar nada:
| Defesa | O que ela barra | Configuração |
|---|---|---|
| Honeypot | Bots que preenchem todos os inputs | O input oculto _gotcha |
| Pontuação de spam | Links no nome (+3), muitos links (+2), frases de spam conhecidas (+2) e endereços descartáveis. Com 5, uma mensagem é armazenada como spam | Nenhuma |
| Limites de taxa | Scripts enviando em loop | Ligado por padrão: Standard, 5 por minuto por visitante |
| Domínios permitidos | Cópias do seu formulário enviando de outros sites | Adicione seus domínios nas configurações do formulário |
| Verificação humana | Bots que controlam um navegador de verdade | Defina Spam check como Ikiform para usar o Cloudflare Turnstile sem chaves |
Spam recebe a mesma resposta de sucesso de uma mensagem real, então o bot não aprende nada, e não dispara e-mail nem webhook. Um formulário de contato raramente precisa de mais de um envio da mesma pessoa em poucos minutos, então o Strict, 2 a cada 10 minutos por visitante, combina com ele quando estiver no ar. A documentação de spam explica cada camada, e a verificação humana precisa de uma chamada extra nas versões em JavaScript: challenge() do SDK.
Domínios permitidos e deploys de preview
Allowed domains corresponde a um domínio e aos seus subdomínios. Hosts estáticos dão mais domínios do que você imagina: o domínio de produção, o subdomínio do próprio host e um endereço de preview por branch ou pull request. Alguns previews são subdomínios do endereço do seu site no host e já correspondem; outros não, e precisam de uma entrada própria.
| Host | Adicione | Previews |
|---|---|---|
| GitHub Pages | username.github.io e seu domínio personalizado | Sem previews por padrão |
| Vercel | your-project.vercel.app e seu domínio | URLs de branch não correspondem; adicione o host exato enquanto testa |
| Netlify | your-site.netlify.app e seu domínio | deploy-preview-12--your-site.netlify.app não corresponde |
| Cloudflare Pages | your-project.pages.dev e seu domínio | abc123.your-project.pages.dev já corresponde |
Nunca adicione um host compartilhado sozinho, como github.io, vercel.app ou netlify.app. Todo site naquele host passaria a corresponder, inclusive os de outras pessoas.
Se o seu site envia uma Content-Security-Policy
Muitos templates estáticos vêm com uma Content-Security-Policy rígida. Duas diretivas decidem se o formulário funciona: form-action para o envio de HTML simples e connect-src para as versões com fetch. Deixe uma delas de fora e o navegador bloqueia a requisição antes de ela sair da página, com só uma mensagem no console como pista.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comMantenha 'self' em form-action: alguns navegadores também conferem o redirecionamento de volta para a sua página de agradecimento com ela. Onde o header fica depende do host: _headers na Netlify e no Cloudflare Pages, vercel.json na Vercel, uma tag <meta> no GitHub Pages e uma response headers policy do CloudFront na frente do S3. As páginas de cada host abaixo mostram como.
Teste antes do deploy
Abra a página por um servidor local, não clicando duas vezes no arquivo. Uma página aberta como file:// envia Origin: null, que não corresponde a nenhum domínio permitido e não conta como teste local. Qualquer um destes serve a pasta atual:
npx serve .
python3 -m http.server 8000Adicione localhost em Allowed domains enquanto testa. Envios de localhost contam como testes: um workspace sem plano tem 50 por mês, então você pode construir o formulário inteiro antes de pagar.
Escolha o seu caso
O formulário acima funciona em todo host estático. Cada host tem o próprio jeito de falhar, e os próprios domínios de preview e arquivos de headers. Estas páginas tratam deles.
- GitHub PagesO GitHub Pages não roda código de servidor, então um POST de formulário recebe 405 Not Allowed; envie para um backend de formulários e acerte os redirecionamentos de project sites.
- VercelUma página estática na Vercel responde a um POST de formulário com 405 Method Not Allowed; veja por quê e como enviar o formulário para um backend.
- NetlifyRode um formulário de contato num site da Netlify sem o Netlify Forms: sem detecção no build, envios JSON a partir do React e um formulário separado para deploy previews.
- Cloudflare PagesAdicione um formulário de contato ao Cloudflare Pages sem escrever uma Function: envie para um backend de formulários, defina headers no _headers e use suas próprias chaves do Turnstile.
- Amazon S3Endpoints de site do S3 só aceitam GET e HEAD via HTTP simples, então envie o formulário de contato para um backend de formulários e acerte os redirecionamentos com o CloudFront.
- Site de portfólioUm formulário de contato para portfólio que faz as perguntas de uma primeira conversa: projeto, orçamento, prazo e um link, sem servidor e com respostas pela sua caixa de entrada.
Confira se funciona
Faça isto uma vez no seu domínio publicado e uma vez num preview, se o seu host gera previews. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Envie a partir do seu servidor local e confirme que a mensagem aparece em Submissions.
- Faça o deploy e envie a partir do site publicado com o JavaScript desligado. Você deve cair na sua própria página de agradecimento.
- Ligue o JavaScript de novo e envie com a mensagem vazia. O erro deve aparecer na página, não como uma página de erro hospedada.
- Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email. - Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Não chegou nada? Mude o filtro de status do painel para Spam. Spam recebe a mesma resposta de sucesso de um envio real.
Solução de problemas
| Problema | O que fazer |
|---|---|
405 Method Not Allowed ou 404 logo depois de clicar em Enviar | O formulário enviou para o seu próprio site. Confira o atributo action no HTML publicado, não no seu código-fonte; uma etapa de build ou um componente <Form> do framework pode tê-lo substituído. |
Nada acontece, e o console menciona Content-Security-Policy | Adicione https://ikiform.com a form-action para envios simples e a connect-src para fetch. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
Testes locais são recusados com 402 plan_required | A página foi aberta como uma URL file://, então o envio não tinha uma origem utilizável e contou como envio real. Sirva a pasta com um servidor local e teste a partir de localhost. |
| O navegador cai na página de agradecimento do Ikiform em vez da sua | O _redirect é ignorado, a menos que o domínio dele esteja em Allowed domains e ele seja uma URL https:. Adicione o domínio, ou defina Redirect after submit nas configurações do formulário. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
Perguntas
Sim. A página continua estática; só o action do formulário aponta para um serviço que aceita envios. Um backend de formulários armazena cada mensagem, filtra spam e manda um e-mail para você, então você nunca precisa rodar um servidor.
Sim. Um id de formulário é feito para ser público, do mesmo jeito que um endereço de e-mail numa página de contato. Proteja o formulário com domínios permitidos, o honeypot, limites de taxa e a verificação humana. O que nunca pode estar num site estático é uma chave de API secreta.
Ele só funciona quando o visitante tem um app de e-mail configurado, não armazena nada e coloca seu endereço na página para os scrapers. Um formulário mantém o visitante no seu site e dá a você um registro de cada mensagem.
Não. Hosts estáticos não rodam PHP nem nenhum outro código de servidor para as suas páginas. Um script de e-mail em PHP precisa de um servidor, que é justamente o que um site estático evita.
Ainda não. Um envio que contém um arquivo é recusado com 422 files_not_supported. Peça um link para o arquivo, como a URL de um drive compartilhado.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.