# Crie um formulário de inscrição em newsletter no Nuxt

> Um formulário de newsletter no Nuxt que funciona com ou sem JavaScript: uma server route com verificação Turnstile envia ao Ikiform, e uma rota Nitro alimenta o beehiiv.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/newsletter-signup/nuxt

Dê ao formulário um `action` de verdade apontando para uma server route do Nuxt e aprimore-o com `$fetch` para que leitores com JavaScript fiquem na página. A rota confere o checkbox de consentimento e um token do Turnstile vindo de `@nuxtjs/turnstile`, encaminha a inscrição ao seu backend de formulários com uma chave secreta e responde JSON ou um redirecionamento conforme quem pediu. Uma segunda rota transforma o webhook assinado do backend numa assinatura do beehiiv.

A [aba Nuxt do guia principal](https://ikiform.com/pt-br/guides/newsletter-signup.md#form) envia com `$fetch` e funciona depois que o Vue hidrata. Esta página faz o mesmo formulário funcionar antes disso, adiciona uma verificação humana e repassa as inscrições reais ao beehiiv. A configuração do produto está no [guia do Nuxt na documentação](https://ikiform.com/pt-br/docs/guides/nuxt.md).

## Uma rota, dois tipos de chamador

Uma server route do Nuxt não sabe se foi chamada por um formulário do navegador ou por `$fetch`, então decide pelo header `Accept`. O `$fetch` do componente pede JSON e recebe JSON ou um erro com um motivo. Um envio de formulário simples, antes da hidratação ou com scripts desligados, pede HTML, então a rota responde `303` para `/newsletter` com um código de resultado:

Diagrama: Como a rota de newsletter do Nuxt responde a cada tipo de chamador

1. O formulário envia a /api/newsletter, por $fetch depois de hidratado ou como envio de formulário simples antes disso.
2. A rota confere o consentimento e o token do Turnstile, e então encaminha a inscrição ao Ikiform.
3. Ela lê o header Accept para decidir como responder.
4. O $fetch pediu JSON: o sucesso devolve JSON, e um problema lança um erro com um motivo que o componente mostra.
5. Um envio de formulário simples recebe um redirecionamento 303 para /newsletter com um código de resultado, e a página transforma o código numa mensagem.

O redirecionamento leva um código, nunca uma mensagem, então um link forjado não consegue colocar um texto próprio na sua página.

## Configuração do projeto

Instale o módulo do Turnstile com `npx nuxi@latest module add turnstile` e depois:

`Árvore do projeto`:

```text
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/turnstile"],
  turnstile: { siteKey: "your_turnstile_site_key" },
  runtimeConfig: {
    ikiformSecretKey: "",
    ikiformWebhookSecret: "",
    beehiivApiKey: "",
    beehiivPublicationId: "",
    turnstile: { secretKey: "" },
  },
  routeRules: {
    "/blog/**": { prerender: true },
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
NUXT_BEEHIIV_API_KEY=your_beehiiv_api_key
NUXT_BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
NUXT_TURNSTILE_SECRET_KEY=your_turnstile_secret_key
```

Toda chave em `runtimeConfig` é sobrescrita em runtime pela variável `NUXT_` correspondente, então a config guarda strings vazias e o host guarda os valores. Nada fora de `runtimeConfig.public` chega ao navegador. A site key do Turnstile é pública por natureza. Crie a chave do Ikiform em **API keys** com o escopo `submissions:write` e cole a [definição de campos do pilar](https://ikiform.com/pt-br/guides/newsletter-signup.md#fields) na aba **Fields** do formulário.

## Construa

### A server route

`server/api/newsletter.post.ts`:

```ts
interface Signup {
  email?: string;
  first_name?: string;
  consent?: boolean | string;
  source?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

type Result = "subscribed" | "consent" | "check" | "email" | "failed";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export default defineEventHandler(async (event) => {
  const body = (await readBody<Signup>(event)) ?? {};
  const wantsJson = getRequestHeader(event, "accept")?.includes("application/json") ?? false;
  const config = useRuntimeConfig(event);

  const finish = (result: Result, status = 200, message = "") => {
    if (!wantsJson) return sendRedirect(event, `/newsletter?result=${result}`, 303);
    if (result === "subscribed") return { subscribed: true };
    throw createError({ status, message, data: { reason: result } });
  };

  if (body.consent !== true && body.consent !== "true") {
    return finish("consent", 422, "Tick the box so we know you want the emails.");
  }
  const check = await verifyTurnstileToken(String(body["cf-turnstile-response"] ?? ""), event);
  if (!check.success) return finish("check", 403, "The human check didn't pass. Try once more.");

  const source = PLACEMENTS.includes(String(body.source)) ? String(body.source) : "footer";
  const origin = getRequestHeader(event, "origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: String(body.email ?? "").trim(),
      first_name: String(body.first_name ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source,
      _gotcha: String(body._gotcha ?? ""),
    }),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return finish("subscribed");
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return finish("email", 422, emailError);
  return finish("failed", response?.status ?? 502, result?.error?.message ?? "We couldn't reach the list. Try again.");
});
```

- `readBody` interpreta tanto o JSON do `$fetch` quanto o corpo URL-encoded de um envio de formulário simples. Por isso o consentimento é aceito como `true` ou `"true"`.
- `verifyTurnstileToken` vem do módulo do Turnstile e lê `runtimeConfig.turnstile.secretKey`. Os tokens são de uso único e expiram depois de cinco minutos.
- A linha `Origin` repassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos em `localhost` por esse header e o `fetch` de um servidor não envia nenhum.
- `useRuntimeConfig(event)` obtém os valores com os overrides `NUXT_` de runtime aplicados, o que o Nuxt recomenda em server routes.

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

### O componente do formulário

`app/components/NewsletterForm.vue`:

```vue
<script setup lang="ts">
const props = withDefaults(defineProps<{ source?: "footer" | "post" | "newsletter-page" }>(), { source: "footer" });

const turnstile = ref<{ reset: () => void }>();
const token = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const problem = ref<{ reason: string; message: string } | null>(null);

async function subscribe(event: Event) {
  const fields = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  problem.value = null;
  try {
    await $fetch("/api/newsletter", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: { ...fields, consent: fields.consent === "true", "cf-turnstile-response": token.value },
    });
    status.value = "done";
  } catch (failure) {
    const data = (failure as { data?: { message?: string; data?: { reason?: string } } }).data;
    problem.value = { reason: data?.data?.reason ?? "failed", message: data?.message ?? "Couldn't subscribe. Try again." };
    turnstile.value?.reset();
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
  <form v-else action="/api/newsletter" method="post" @submit.prevent="subscribe">
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" :aria-invalid="problem?.reason === 'email'" />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required :aria-invalid="problem?.reason === 'consent'" />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" :value="props.source" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <NuxtTurnstile ref="turnstile" v-model="token" />
    <p v-if="problem" role="alert">{{ problem.message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
  </form>
</template>
```

- O formulário mantém `action` e `method`, então envia do jeito tradicional até o `@submit.prevent` assumir depois da hidratação.
- `<NuxtTurnstile>` renderiza o widget e coloca o token dele em `token`. Depois de qualquer erro o token já foi gasto, então `reset()` pede um novo.
- Os campos são lidos do `FormData` antes do primeiro `await`, enquanto `event.currentTarget` ainda aponta para o formulário.

### A página /newsletter

`app/pages/newsletter.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  subscribed: "Almost done. Open the email we sent you and confirm your address.",
  consent: "Tick the box so we know you want the emails.",
  check: "The human check needs JavaScript. Turn it on and try again.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

const route = useRoute();
const message = computed(() => MESSAGES[String(route.query.result)] ?? "");
</script>

<template>
  <main>
    <h1>The monthly newsletter</h1>
    <p v-if="message" :role="route.query.result === 'subscribed' ? 'status' : 'alert'">{{ message }}</p>
    <NewsletterForm v-if="route.query.result !== 'subscribed'" source="newsletter-page" />
  </main>
</template>
```

O Turnstile precisa de JavaScript, então um leitor com scripts desligados recebe a mensagem `check`. Se essa troca não serve para seus leitores, remova as linhas do Turnstile e conte com o honeypot e o checkbox obrigatório.

## Encaminhe assinantes ao beehiiv

Esta rota fica em `server/routes`, então não tem o prefixo `/api`. Ela lê o corpo com `readRawBody` antes que qualquer coisa o interprete, porque a assinatura cobre os bytes exatos, e cria a assinatura com `double_opt_override: "on"` para que o beehiiv envie a confirmação. O [guia do beehiiv](https://ikiform.com/pt-br/guides/newsletter-signup/beehiiv.md) acrescenta o primeiro nome e os headers de limite de taxa do próprio beehiiv.

`server/routes/hooks/ikiform.post.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event);
  let delivery;
  try {
    delivery = await verifyWebhook({
      payload: (await readRawBody(event)) ?? "",
      headers: event.headers,
      secret: config.ikiformWebhookSecret,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      setResponseStatus(event, 400);
      return error.reason;
    }
    throw error;
  }
  if (delivery.type !== "submission.created") return sendNoContent(event);

  const { email, consent, source } = delivery.data.fields;
  if (consent !== true || typeof email !== "string") return "Skipped";

  const answer = await fetch(
    `https://api.beehiiv.com/v2/publications/${config.beehiivPublicationId}/subscriptions`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${config.beehiivApiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        email,
        double_opt_override: "on",
        reactivate_existing: false,
        utm_source: typeof source === "string" ? source : "website",
        referring_site: delivery.data.meta.referrer ?? undefined,
      }),
    },
  );
  if (answer.status === 429) {
    setResponseHeader(event, "retry-after", 60);
    setResponseStatus(event, 503);
    return "beehiiv rate limit";
  }
  if (!answer.ok) {
    setResponseStatus(event, 502);
    return `beehiiv answered ${answer.status}`;
  }
  return sendNoContent(event);
});
```

Adicione `https://your-site.com/hooks/ikiform` em **Webhooks**, escolha o formulário da newsletter e copie o segredo `whsec_` para `NUXT_IKIFORM_WEBHOOK_SECRET`.

## Honeypot e a verificação humana

O input `_gotcha` é repassado, e o Ikiform armazena um preenchido como spam, com resposta de sucesso e sem webhook, então o beehiiv nunca o vê. A chave secreta pula a verificação Turnstile do próprio Ikiform, e é por isso que a rota roda uma com o seu próprio widget.

## Renderização: híbrida, não generate

`nuxt generate` gera um site estático sem servidor, então `/api/newsletter` e `/hooks/ikiform` não existem depois dele. Faça o build com `nuxt build` e pré-renderize as páginas que podem ser estáticas via `routeRules`, como a config acima faz para `/blog/**`. As páginas pré-renderizadas mantêm o formulário, e o envio dele continua chegando à server route.

## Deploy

| Host | O que fazer |
| --- | --- |
| Vercel | O Nitro detecta a Vercel e gera as server routes como funções. Adicione as variáveis `NUXT_` em **Environment Variables**. |
| Netlify | O Nitro detecta a Netlify do mesmo jeito. Adicione as variáveis nas configurações de ambiente do site. |
| Cloudflare | Faça o build com `nuxt build`, não `nuxt generate`, e defina as variáveis `NUXT_` como segredos no projeto. Use um domínio personalizado para a URL do webhook; o Ikiform recusa `workers.dev` e `pages.dev`. |

## Teste em localhost de graça

Um workspace sem plano aceita 50 envios de teste por mês vindos de `localhost`, e o `Origin` repassado faz cada inscrição feita no `nuxt dev` contar como um deles. Use localmente as chaves de teste do Turnstile publicadas pela Cloudflare para que a verificação sempre passe. O Ikiform não entrega webhooks em `localhost`; exponha o servidor de desenvolvimento por um túnel com URL HTTPS para testar `/hooks/ikiform`.

## Confira se funciona

1. Rode `nuxt dev` e inscreva-se pelo rodapé. O formulário é substituído pela frase de confira-sua-caixa-de-entrada e a URL não muda.
2. Desative o JavaScript e inscreva-se. Você cai em `/newsletter?result=check`, porque o widget não conseguiu rodar.
3. Com o JavaScript ligado, remova `required` do checkbox e envie sem marcar. A mensagem de consentimento aparece e nada é armazenado.
4. Abra **Submissions** no painel. O novo envio está lá com todos os campos que você enviou.
5. Se você adicionou um webhook, abra o endpoint em **Webhooks**. A entrega mostra `succeeded` com a resposta do seu servidor.
6. Abra o beehiiv. A assinatura está lá, pendente até você confirmar, e o e-mail de confirmação está na sua caixa de entrada.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `404` em `/api/newsletter` em produção | O site foi gerado com `nuxt generate`, que não tem servidor. Faça o build com `nuxt build` e pré-renderize as páginas via `routeRules`. |
| A chave secreta está vazia em runtime | O nome da variável não corresponde à chave da config. `ikiformSecretKey` precisa de `NUXT_IKIFORM_SECRET_KEY`. |
| A verificação humana falha a cada duas tentativas | O token foi reutilizado. Chame `reset()` no widget depois de cada erro, como o componente faz. |
| `402 plan_required` em testes locais | O envio não tinha origem `localhost`. Confira a linha `Origin` e abra o site em `localhost`. |
| 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. |

## Perguntas

### Um formulário Nuxt funciona sem JavaScript?

Sim, se ele mantiver um action e um method de verdade. Aponte-o para uma server route que redireciona envios de formulário simples e devolve JSON para o $fetch, como nesta página.

### O nuxt generate suporta server routes para um formulário de newsletter?

Não. O nuxt generate gera só arquivos estáticos, então as server routes ficam de fora. Use nuxt build com routeRules para pré-renderizar as páginas estáticas, ou envie o formulário do navegador direto ao Ikiform.

### Por que mandar assinantes ao beehiiv por um webhook em vez da server route?

A server route só vê a resposta do Ikiform, e o spam recebe o mesmo sucesso de uma inscrição real. O beehiiv só deve saber de um leitor depois da checagem de spam, que é quando o webhook dispara.

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

- [Formulário de inscrição em newsletter](https://ikiform.com/pt-br/guides/newsletter-signup.md): Crie um formulário de newsletter no seu próprio markup, registre o consentimento, barre inscrições de bots e passe cada assinante para Kit, MailerLite, beehiiv ou qualquer ferramenta.
- [beehiiv](https://ikiform.com/pt-br/guides/newsletter-signup/beehiiv.md): Conecte seu próprio formulário de inscrição ao beehiiv: um relay cria cada subscription com double opt-in ligado, a posição como UTM source e a página como referrer.
- [Double opt-in](https://ikiform.com/pt-br/guides/newsletter-signup/double-opt-in.md): O Ikiform não envia e-mails de confirmação, então o double opt-in roda na sua ferramenta de e-mail: um relay adiciona cada inscrição como não confirmada e a ferramenta envia o link.
- [Formulário de newsletter no SvelteKit](https://ikiform.com/pt-br/guides/newsletter-signup/sveltekit.md): Um formulário de newsletter no layout do SvelteKit 3: uma form action com consentimento e limite por IP, use:enhance que fica na página e uma rota de webhook para o Kit.
- [Site estático](https://ikiform.com/pt-br/guides/newsletter-signup/static-site.md): Adicione um formulário de newsletter a um site estático em Hugo, Jekyll, Eleventy ou Astro, mantenha a chave de API da ferramenta de e-mail fora da página e confirme na ferramenta.
- [Kit (ConvertKit)](https://ikiform.com/pt-br/guides/newsletter-signup/kit.md): Conecte seu próprio formulário de newsletter ao Kit com um relay que cria o assinante, o adiciona a um formulário do Kit para o incentive email e marca a posição com uma tag.
