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

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup/nuxt

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](https://ikiform.com/guides/newsletter-signup.md#form) 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](https://ikiform.com/docs/guides/nuxt.md).

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

Diagram: How the Nuxt newsletter route answers each kind of caller

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

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

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](https://ikiform.com/guides/newsletter-signup.md#fields) on the form's **Fields** tab.

## Build it

### The 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` 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](https://ikiform.com/docs/rate-limits.md) 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`:

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

```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](https://ikiform.com/guides/newsletter-signup/beehiiv.md) adds the first name and beehiiv's own rate limit headers.

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

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

| Host | What to do |
| --- | --- |
| Vercel | Nitro detects Vercel and builds server routes as functions. Add the `NUXT_` variables under **Environment Variables**. |
| Netlify | Nitro detects Netlify the same way. Add the variables in the site's environment settings. |
| Cloudflare | Build 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

| Problem | What to do |
| --- | --- |
| `404` on `/api/newsletter` in production | The 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 runtime | The variable's name doesn't match the config key. `ikiformSecretKey` needs `NUXT_IKIFORM_SECRET_KEY`. |
| The human check fails on every second try | The token was reused. Call `reset()` on the widget after each error, as the component does. |
| `402 plan_required` on local tests | The 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 arrived | It 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

### Can a Nuxt form work without JavaScript?

Yes, if it keeps a real action and method. Point it at a server route that redirects for plain form posts and returns JSON for $fetch, as on this page.

### Does nuxt generate support server routes for a newsletter form?

No. nuxt generate outputs static files only, so server routes are missing. Use nuxt build with routeRules to prerender the static pages, or post the form from the browser straight to Ikiform.

### Why send subscribers to beehiiv from a webhook instead of the server route?

The server route only sees Ikiform's answer, and spam gets the same success as a real signup. beehiiv should hear about a reader only after the spam check, which is when the webhook fires.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md): Connect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- [Double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md): Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.
- [SvelteKit newsletter form](https://ikiform.com/guides/newsletter-signup/sveltekit.md): A SvelteKit 3 newsletter form in the layout: a form action with consent and an IP limit, use:enhance that stays on the page, and a Kit webhook route.
- [Static site](https://ikiform.com/guides/newsletter-signup/static-site.md): Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
