Newsletter signup form

Newsletter signup form7 min read

Build a newsletter signup form in Nuxt

A Nuxt newsletter form that works with or without JavaScript: a server route with a Turnstile check posts to Ikiform, and a Nitro route feeds beehiiv.

Preet SutharFounder, Ikiform

Give the form a real action pointing at a Nuxt server route, and enhance it with $fetch so readers with JavaScript stay on the page. The route checks the consent box and a Turnstile token from @nuxtjs/turnstile, forwards the signup to your form backend with a secret key, and answers JSON or a redirect depending on who asked. A second route turns the backend's signed webhook into a beehiiv subscription.

The main guide's Nuxt tab posts with $fetch and works once Vue has hydrated. This page makes the same form work before that, adds a human check, and sends real signups on to beehiiv. Product setup is in the Nuxt docs guide.

One route, two kinds of caller

A Nuxt server route doesn't know whether a browser form or $fetch called it, so it decides from the Accept header. $fetch from the component asks for JSON and gets JSON or an error with a reason. A plain form post, before hydration or with scripts off, asks for HTML, so the route answers 303 to /newsletter with a result code:

How the Nuxt newsletter route answers each kind of callerNewsletterFormaction="/api/newsletter"newsletter.post.tsconsent,Turnstile,IkiformAccept:JSON?whoaskedJSONorerror$fetch,samepage303/newsletter?result=...newsletter.vueshowsthemessage$fetch or form POSTresultyesnoHow the Nuxt newsletter route answers each kind of callerNewsletterFormaction="/api/newsletter"newsletter.post.tsconsent, Turnstile,IkiformAccept:JSON?who askedJSON or error$fetch, same page303/newsletter?result=...newsletter.vueshows themessage$fetch or form POSTresultyesno
  1. The form posts to /api/newsletter, through $fetch once hydrated or as a plain form post before that.
  2. The route checks consent and the Turnstile token, then forwards the signup to Ikiform.
  3. It reads the Accept header to decide how to answer.
  4. $fetch asked for JSON: success returns JSON, and a problem throws an error with a reason the component shows.
  5. A plain form post gets a 303 redirect to /newsletter with a result code, and the page turns the code into a message.
The redirect carries a code, never a message, so a crafted link can't put its own text on your page.

Project setup

Install the Turnstile module with npx nuxi@latest module add turnstile, then:

Project tree
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

Every key under runtimeConfig is overridden at runtime by the matching NUXT_ variable, so the config holds empty strings and the host holds the values. Nothing outside runtimeConfig.public reaches the browser. The Turnstile site key is public by design. Create the Ikiform key under API keys with the submissions:write scope and paste the pillar's field definition on the form's Fields tab.

Build it

The 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 parses both JSON from $fetch and the URL-encoded body of a plain form post. That's why consent is accepted as true or "true".
  • verifyTurnstileToken comes from the Turnstile module and reads runtimeConfig.turnstile.secretKey. Tokens are single-use and expire after five minutes.
  • The Origin line passes the browser's origin on, because Ikiform recognises free localhost tests by that header and a server's fetch sends none.
  • useRuntimeConfig(event) gets the values with runtime NUXT_ overrides applied, which Nuxt recommends in server routes.
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

The form component

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>
  • The form keeps action and method, so it posts the plain way until @submit.prevent takes over after hydration.
  • <NuxtTurnstile> renders the widget and puts its token in token. After any error the token is spent, so reset() asks for a new one.
  • The fields are read from FormData before the first await, while event.currentTarget still points at the form.

The /newsletter page

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>

Turnstile needs JavaScript, so a reader with scripts off gets the check message. If that trade is wrong for your readers, remove the Turnstile lines and rely on the honeypot and the required box.

Forward subscribers to beehiiv

This route sits in server/routes, so it has no /api prefix. It reads the body with readRawBody before anything parses it, because the signature covers the exact bytes, and creates the subscription with double_opt_override: "on" so beehiiv sends the confirmation. The beehiiv guide adds the first name and beehiiv's own rate limit headers.

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

Add https://your-site.com/hooks/ikiform under Webhooks, pick the newsletter form and copy the whsec_ secret into NUXT_IKIFORM_WEBHOOK_SECRET.

Honeypot and the human check

The _gotcha input is forwarded and Ikiform stores a filled one as spam, with a success answer and no webhook, so beehiiv never sees it. The secret key skips Ikiform's own Turnstile check, which is why the route runs one with your own widget.

Rendering: hybrid, not generate

nuxt generate writes a static site with no server, so /api/newsletter and /hooks/ikiform don't exist after it. Build with nuxt build and prerender the pages that can be static through routeRules, as the config above does for /blog/**. Prerendered pages keep the form, and its post still reaches the server route.

Deploy

HostWhat to do
VercelNitro detects Vercel and builds server routes as functions. Add the NUXT_ variables under Environment Variables.
NetlifyNitro detects Netlify the same way. Add the variables in the site's environment settings.
CloudflareBuild with nuxt build, not nuxt generate, and set the NUXT_ variables as secrets on the project. Use a custom domain for the webhook URL; Ikiform refuses workers.dev and pages.dev.

Test it on localhost for free

A workspace without a plan accepts 50 test submissions a month from localhost, and the forwarded Origin makes each signup from nuxt dev one of them. Use Cloudflare's published Turnstile testing keys locally so the check always passes. Ikiform won't deliver webhooks to localhost; expose the dev server through a tunnel with an HTTPS URL to test /hooks/ikiform.

Check it works

  1. Run nuxt dev and subscribe from the footer. The form is replaced with the check-your-inbox line and the URL doesn't change.
  2. Disable JavaScript and subscribe. You land on /newsletter?result=check, because the widget couldn't run.
  3. With JavaScript on, remove required from the checkbox and submit unticked. The consent message appears and nothing is stored.
  4. Open Submissions in the dashboard. The new submission is there with every field you sent.
  5. If you added a webhook, open the endpoint in Webhooks. The delivery shows succeeded with your server's answer.
  6. Open beehiiv. The subscription is there, pending until you confirm, and the confirmation email is in your inbox.

Gotchas

ProblemWhat to do
404 on /api/newsletter in productionThe site was built with nuxt generate, which has no server. Build with nuxt build and prerender pages through routeRules.
The secret key is empty at runtimeThe variable's name doesn't match the config key. ikiformSecretKey needs NUXT_IKIFORM_SECRET_KEY.
The human check fails on every second tryThe token was reused. Call reset() on the widget after each error, as the component does.
402 plan_required on local testsThe post had no localhost origin. Check the Origin line, and open the site at localhost.
The submission is in the dashboard but no email or webhook arrivedIt was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.