Formulário de inscrição em newsletter

Formulário de inscrição em newsletter8 min de leitura

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.

Preet SutharFundador, Ikiform

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

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:

Como a rota de newsletter do Nuxt responde a cada tipo de chamadorNewsletterFormaction="/api/newsletter"newsletter.post.tsconsentimento,Turnstile,IkiformAccept:JSON?quempediuJSONouerro$fetch,mesmapágina303/newsletter?result=...newsletter.vuemostraamensagem$fetch ou POST do formulárioresultadosimnãoComo a rota de newsletter do Nuxt responde a cada tipo de chamadorNewsletterFormaction="/api/newsletter"newsletter.post.tsconsentimento,Turnstile, IkiformAccept:JSON?quem pediuJSON ou erro$fetch, mesma página303/newsletter?result=...newsletter.vuemostra amensagem$fetch ou POST do formulárioresultadosimnão
  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
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
nuxt.config.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
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 na aba Fields do formulário.

Construa

A server route

server/api/newsletter.post.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 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
<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
<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 acrescenta o primeiro nome e os headers de limite de taxa do próprio beehiiv.

server/routes/hooks/ikiform.post.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

HostO que fazer
VercelO Nitro detecta a Vercel e gera as server routes como funções. Adicione as variáveis NUXT_ em Environment Variables.
NetlifyO Nitro detecta a Netlify do mesmo jeito. Adicione as variáveis nas configurações de ambiente do site.
CloudflareFaç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

ProblemaO que fazer
404 em /api/newsletter em produçãoO 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 runtimeO 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 tentativasO token foi reutilizado. Chame reset() no widget depois de cada erro, como o componente faz.
402 plan_required em testes locaisO 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 chegouEle 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.