# Build a newsletter signup form in Astro

> An Astro newsletter form that stays on prerendered pages: an Astro Action checks consent, posts to Ikiform, and an endpoint adds readers to MailerLite.

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

Keep your Astro pages prerendered and put the signup form in the footer component. Without JavaScript it posts to one on-demand `/newsletter` page that runs an Astro Action; with JavaScript a small script calls the same action and the reader never leaves the article. The action validates the email and consent with `astro/zod`, posts to your form backend with a secret key, and an API endpoint forwards verified signups to MailerLite.

The [main guide's Astro tab](https://ikiform.com/guides/newsletter-signup.md#form) uses an Action on a page rendered on demand. Most Astro sites are blogs and docs where every page is prerendered, and a footer form has to live on all of them. This page keeps those pages static and still runs the signup on the server. Keys and allowed domains are in the [Astro docs guide](https://ikiform.com/docs/guides/astro.md).

## What static pages change

Astro's docs say a page must be rendered on demand to call an action through a form's `action` attribute. A prerendered article can't handle that POST, because there's no server behind it. Two routes around it, and this build uses both:

| Path | When it runs | What the reader sees |
| --- | --- | --- |
| Script calls `actions.subscribe()` | JavaScript loaded, the usual case | The status line under the form changes. They stay on the article |
| Form posts to `/newsletter?_action=...` | JavaScript off or not loaded yet | The on-demand `/newsletter` page, with the result or the error |

Only `/newsletter` and the webhook endpoint run on demand. Every article stays a static file, and the adapter adds a server function for those two routes.

Diagram: Two ways an Astro footer signup reaches the same action

1. With JavaScript, the footer script stops the normal post and calls the action with the form's data.
2. The action validates the input, posts to Ikiform, and returns data or an error to the script, which updates the status line.
3. Without JavaScript, the browser posts the form to the on-demand /newsletter page with the action in the query string.
4. Astro runs the same action handler, and the page reads the outcome with getActionResult and shows it.

One handler, two entry points. The static article never needs a server of its own.

## Project setup

`Project tree`:

```text
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env
```

Install an adapter with `astro add vercel`, `astro add netlify` or `astro add cloudflare`. Declare the secrets with `astro:env` rather than reading `import.meta.env`: the schema fails the build when a variable is missing, and the same import works on every adapter, including Cloudflare's runtime.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Create the secret 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 action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email({ error: "Enter an email address we can send the newsletter to." }),
      first_name: z.string().trim().max(80).nullish(),
      consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
      source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          first_name: input.first_name || undefined,
          consent: true,
          source: input.source,
          _gotcha: input._gotcha ?? "",
        }),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { subscribed: true };
      throw new ActionError({
        code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` makes Astro parse `FormData`. The checkbox goes through boolean coercion, so ticked is `true` and unticked is `false`, which `refine` turns into the consent message.
- Empty text inputs arrive as `null`, so optional fields use `nullish()`, not `optional()`.
- `source` falls back to `footer` for any value outside the list, so a crafted post can't invent placements.
- 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.
- Ikiform's errors become an `ActionError`. A `429` keeps its meaning as `TOO_MANY_REQUESTS`.

> 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 footer component

`src/components/SignupForm.astro`:

```astro
---
import { actions } from "astro:actions";

interface Props {
  source?: "footer" | "post" | "newsletter-page";
}

const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---

<form class="signup" method="POST" action={target.pathname + target.search}>
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
  <label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
  <label>
    <input name="consent" type="checkbox" required />
    Email me the newsletter. I can unsubscribe at any time.
  </label>
  <input type="hidden" name="source" value={source} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p class="signup-status" role="status"></p>
  <button>Subscribe</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
    const status = form.querySelector(".signup-status") as HTMLElement;
    const button = form.querySelector("button") as HTMLButtonElement;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.textContent = "Subscribing";
      const { error } = await actions.subscribe(new FormData(form));
      button.disabled = false;
      if (!error) {
        form.replaceChildren(status);
        status.textContent = "Almost done. Open the email we sent you and confirm your address.";
        return;
      }
      const fields = isInputError(error) ? error.fields : {};
      status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
    });
  }
</script>
```

- `queryString` is the documented way to build an action URL. Setting it on a `/newsletter` URL sends no-JavaScript posts to the on-demand page instead of the static one they came from.
- Astro bundles a component's `<script>` once, however many times the component appears. The loop wires up every copy, such as a footer form and one at the end of a post.
- `isInputError` separates zod's per-field messages from Ikiform's refusals, which arrive as a plain message.

### The on-demand page

`src/pages/newsletter.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---

<Base title="Newsletter">
  <h1>The monthly newsletter</h1>
  {result?.data?.subscribed ? (
    <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
  ) : (
    <>
      {problem && <p role="alert">{problem}</p>}
      <SignupForm source="newsletter-page" />
    </>
  )}
</Base>
```

Astro shows an action's result for that POST only. Visiting the page again clears it, which is fine for a signup. If you'd rather redirect to a thank-you URL, Astro's docs show the session-backed pattern with `getActionContext` in middleware.

## Forward subscribers to MailerLite

This endpoint receives Ikiform's signed webhook and adds each signup to a MailerLite group as `unconfirmed`, so MailerLite sends its own confirmation when **Double opt-in for API and integrations** is on. It reads the body as text first because the signature covers the exact bytes. It sends every signup as `unconfirmed`, which suits a new list. If your list already has confirmed readers, use the relay in the [MailerLite guide](https://ikiform.com/guides/newsletter-signup/mailerlite.md) instead: it looks the address up first, so a confirmed reader who signs up again keeps their status.

`src/pages/api/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

Astro's `security.checkOrigin`, on by default, rejects cross-site form posts to on-demand routes. Ikiform's webhook is `application/json`, a type the check doesn't apply to, so the endpoint receives it as is. Add the URL under **Webhooks** and copy the `whsec_` secret into `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 it never reaches MailerLite. The secret key skips Ikiform's Turnstile check. If bots get through, you have two choices: verify a Turnstile token in the handler, as the [Turnstile guide](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md) shows, or drop the action and post from the browser with `@ikiform/sdk`, which keeps Ikiform's check, allowed domains and per-visitor limits and needs no adapter at all.

## Deploy

| Host | What to do |
| --- | --- |
| Vercel | `@astrojs/vercel` turns the two on-demand routes into functions. Add the four variables under **Environment Variables**. |
| Netlify | `@astrojs/netlify` does the same with Netlify Functions. Add the variables in the site's environment settings. |
| Cloudflare | `@astrojs/cloudflare` targets Workers; the adapter no longer supports Pages. Set secrets with `wrangler secret put`, and use `.dev.vars` locally. Give the Worker a custom domain, because Ikiform refuses `workers.dev` webhook URLs. |

## Test it on localhost for free

A workspace without a plan accepts 50 test submissions a month from `localhost`. Run `astro dev` and the forwarded `Origin` is `http://localhost:4321`, so each signup counts as a test. Try both paths: once normally, and once with JavaScript disabled to see the `/newsletter` page. To test the endpoint, expose it through a tunnel with an HTTPS URL; Ikiform won't deliver to `localhost`.

## Check it works

1. Run `astro build` and check that your articles are still listed as prerendered in the output, with only `/newsletter` and `/api/ikiform` on demand.
2. Subscribe from an article's footer. The status line changes and the URL doesn't.
3. Disable JavaScript and subscribe again. You land on `/newsletter` with the check-your-inbox line.
4. Submit with the box unticked from the dev tools. The consent message shows and nothing is stored.
5. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
6. If you added a webhook, open the endpoint in **Webhooks**. The delivery shows `succeeded` with your server's answer.
7. Open MailerLite. The subscriber is in the group as unconfirmed, and the confirmation email is in your inbox.

## Gotchas

| Problem | What to do |
| --- | --- |
| The no-JavaScript post shows a 404 or a static page | The form posted to the prerendered page it came from. Build the `action` from `/newsletter` plus `queryString`, and keep `prerender = false` on that page. |
| Only the first form on a page works | The script used `querySelector` for a single form. Astro runs it once per page, so loop over every `form.signup`. |
| The build fails with a missing environment variable | `astro:env` checks the schema at build time. Set the variable on the host, or in `.env` locally. |
| `402 plan_required` on local tests | The post had no `localhost` origin. Check the `Origin` line in the handler, 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 an Astro Action run on a prerendered page?

Called from a client script, yes, as long as the project has an adapter: the action runs on its own on-demand route. A plain HTML form post needs an on-demand page to land on, which is why this build sends it to /newsletter.

### Do I need an adapter for a newsletter form in Astro?

Only for the server-side version on this page. A fully static Astro site can post the form straight from the browser to Ikiform, which keeps Ikiform's human check and needs no server.

### Why forward signups to MailerLite through a webhook instead of in the action?

The action can't tell a bot from a reader: Ikiform gives both the same success answer. Its webhook fires only for signups that pass the spam check, and a MailerLite outage then delays the subscriber through webhook retries instead of failing the visitor's submit.

### 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.
- [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.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
- [Next.js newsletter form](https://ikiform.com/guides/newsletter-signup/nextjs.md): A Next.js footer newsletter form: a server action with a consent check and Turnstile, posting to Ikiform, plus a route handler that adds readers to Kit.
- [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.
- [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.
