# 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.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/contact-form-static-site

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.

Diagrama: Para onde vai o envio de um formulário de contato, antes e depois da correção

1. Sem action, ou com um action que aponta para um caminho no seu próprio site, o navegador envia para o host estático.
2. O host não tem código para rodar num POST, então responde 405 ou 404 e a mensagem se perde.
3. Com o action apontando para o seu endpoint do Ikiform, o envio vai para um servidor que o aceita.
4. O Ikiform verifica se a mensagem é spam, armazena e manda um e-mail para você.
5. O Ikiform responde com um redirecionamento 303, e o navegador carrega a página de agradecimento no seu próprio site.

Só o action do formulário muda. A página, o host e o deploy continuam como estão.

## 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ê.

Diagrama: O que o Ikiform faz com uma mensagem do formulário de contato

1. Um visitante preenche o formulário de contato, e ele é enviado ao seu endpoint do Ikiform.
2. O Ikiform confere o honeypot, o limite de taxa e a pontuação de spam, e então armazena a mensagem.
3. Cada destinatário das notificações recebe um e-mail, com o reply-to definido como o endereço do remetente.
4. A mensagem aparece no painel, onde você pode pesquisá-la, recuperar spam e exportar CSV.
5. 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.

O e-mail basta para a maioria dos formulários de contato. Adicione um webhook quando as mensagens tiverem que chegar onde você já trabalha.

## 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](https://ikiform.com/alternatives/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](https://ikiform.com/alternatives/google-forms), [Tally](https://ikiform.com/alternatives/tally) ou [Typeform](https://ikiform.com/alternatives/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](https://ikiform.com/alternatives/formspree), o [Basin](https://ikiform.com/alternatives/basin) ou o [Web3Forms](https://ikiform.com/alternatives/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](https://ikiform.com/alternatives) 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](https://ikiform.com/pt-br/docs/guides/jekyll.md), [Hugo](https://ikiform.com/pt-br/docs/guides/hugo.md), [Eleventy](https://ikiform.com/pt-br/docs/guides/eleventy.md) e [Gatsby](https://ikiform.com/pt-br/docs/guides/gatsby.md).

**HTML**

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.

`contact.html`:

```html
<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.

`contact.js`:

```js
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;
});
```

**Next.js**

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.

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;
```

`app/contact/contact-form.tsx`:

```tsx
"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.

**Astro**

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.

`src/pages/contact.astro`:

```astro
---
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.

**TanStack Start**

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.

`vite.config.ts`:

```ts
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(),
  ],
});
```

`src/routes/contact.tsx`:

```tsx
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>
  );
}
```

**React Router**

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.

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```

`app/routes/contact.tsx`:

```tsx
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>
  );
}
```

**SvelteKit**

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`.

`src/routes/+layout.ts`:

```ts
export const prerender = true;
```

`src/routes/contact/+page.svelte`:

```svelte
<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**

`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.

`app/pages/contact.vue`:

```vue
<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>
```

**React**

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.

`.env.production`:

```text
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

`src/ContactForm.tsx`:

```tsx
import { 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>
  );
}
```

**Vue**

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.

`src/components/ContactForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` pelo 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 responde `201` em caso de sucesso, ou `422` com um objeto `error.fields` que 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](https://ikiform.com/pt-br/docs/guides/nextjs.md), [SvelteKit](https://ikiform.com/pt-br/docs/guides/sveltekit.md) e [Nuxt](https://ikiform.com/pt-br/docs/guides/nuxt.md) mostram as versões no servidor, e [A partir de um servidor](https://ikiform.com/pt-br/docs/guides/server.md) explica as chaves secretas.

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `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.

Formulário: Contact

- **Nome** (`name`, text, obrigatório)
- **E-mail** (`email`, email, obrigatório)
- **Assunto** (`topic`, select)
- **Mensagem** (`message`, textarea, obrigatório)

A mesma definição do [modelo de formulário de contato](https://ikiform.com/forms/contact-form). Cole-a na aba **Fields** do formulário e o Ikiform a aplica no servidor, diga o HTML o que disser.

| 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.

> Upload de arquivos ainda não é suportado. Um envio com arquivo recebe `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`.

> O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um [webhook](https://ikiform.com/pt-br/docs/webhooks.md).

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](https://ikiform.com/blog/form-submissions-to-slack.md), do [Discord](https://ikiform.com/blog/form-submissions-to-discord.md) ou do [Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md).

## 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.

Diagrama: As três requisições por trás de um envio de formulário HTML simples

1. O navegador envia os campos do formulário ao seu endpoint do Ikiform.
2. O Ikiform armazena a mensagem e responde 303 See Other com um header Location.
3. 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.

Sem um destino de redirecionamento, o passo 3 carrega a página de agradecimento hospedada pelo Ikiform em vez da sua.

O Ikiform escolhe o destino nesta ordem:

- **Redirect after submit** nas configurações do formulário. Qualquer URL `http:` ou `https:` funciona, porque só a sua equipe pode defini-la. É a escolha mais simples para um único site.
- Um campo `_redirect` no formulário, se for uma URL `https:` 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.

`thanks.html`:

```html
<!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](https://ikiform.com/pt-br/docs/spam.md) explica cada camada, e a verificação humana precisa de uma chamada extra nas versões em JavaScript: `challenge()` do [SDK](https://ikiform.com/pt-br/docs/sdk.md).

### 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.

`Header de resposta`:

```text
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

Mantenha `'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:

`Terminal`:

```bash
npx serve .

python3 -m http.server 8000
```

Adicione `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 Pages](https://ikiform.com/pt-br/guides/contact-form-static-site/github-pages.md): O 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.
- [Vercel](https://ikiform.com/pt-br/guides/contact-form-static-site/vercel.md): Uma 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.
- [Netlify](https://ikiform.com/pt-br/guides/contact-form-static-site/netlify.md): Rode 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 Pages](https://ikiform.com/pt-br/guides/contact-form-static-site/cloudflare-pages.md): Adicione 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 S3](https://ikiform.com/pt-br/guides/contact-form-static-site/aws-s3.md): Endpoints 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ólio](https://ikiform.com/pt-br/guides/contact-form-static-site/portfolio-website.md): Um 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](https://ikiform.com/pt-br/pricing).

1. Em **Allowed domains** do formulário, adicione o domínio do seu site, mais `localhost` enquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar.
2. Envie a partir do seu servidor local e confirme que a mensagem aparece em **Submissions**.
3. 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.
4. 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.
5. 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`.
6. 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.
7. 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

### Um site estático pode ter um formulário de contato funcionando?

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.

### É seguro colocar o endpoint do formulário no HTML público?

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.

### Por que não usar um link mailto:?

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.

### Posso usar o mail() do PHP no GitHub Pages ou na Netlify?

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.

### O formulário aceita upload de arquivos?

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.

### O Ikiform é gratuito?

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.

## Mais guias

- [GitHub Pages](https://ikiform.com/pt-br/guides/contact-form-static-site/github-pages.md): O 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.
- [Vercel](https://ikiform.com/pt-br/guides/contact-form-static-site/vercel.md): Uma 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.
- [Netlify](https://ikiform.com/pt-br/guides/contact-form-static-site/netlify.md): Rode 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 Pages](https://ikiform.com/pt-br/guides/contact-form-static-site/cloudflare-pages.md): Adicione 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 S3](https://ikiform.com/pt-br/guides/contact-form-static-site/aws-s3.md): Endpoints 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ólio](https://ikiform.com/pt-br/guides/contact-form-static-site/portfolio-website.md): Um 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.
- [Receber um formulário no e-mail](https://ikiform.com/pt-br/guides/send-form-to-email.md): Receba todo envio de formulário na caixa de entrada sem servidor de e-mail: por que mailto: e mail() do PHP falham, e uma configuração que filtra spam e permite responder.
- [Acabe com o spam no formulário de contato](https://ikiform.com/pt-br/guides/spam-protection.md): Acabe com o spam no formulário de contato com camadas que não custam nada a pessoas reais: honeypot, tempo, pontuação de conteúdo, limites de taxa e verificação humana só quando precisar.
- [Vá além dos formulários do criador](https://ikiform.com/pt-br/guides/website-builder-forms.md): Quando os formulários do Webflow, Netlify, Carrd ou Framer batem num limite ou não têm um recurso, mantenha o formulário que você desenhou e aponte para um backend de formulários.
