Newsletter signup form

Newsletter signup form8 min read

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.

Preet SutharFounder, Ikiform

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

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:

PathWhen it runsWhat the reader sees
Script calls actions.subscribe()JavaScript loaded, the usual caseThe status line under the form changes. They stay on the article
Form posts to /newsletter?_action=...JavaScript off or not loaded yetThe 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.

Two ways an Astro footer signup reaches the same actionwith JavaScriptwithout JavaScriptarticleprerenderedHTMLactions.subscribe()fromtheclientscriptactionhandlerzod,thenfetchstatuslinesamepagearticleplainformpost/newsletterprerender=falseactionhandlersamecoderesultpagegetActionResultFormData/_actions/subscribe{ data, error }POST?_actionresultTwo ways an Astro footer signup reaches the same actionwith JavaScriptwithout JavaScriptarticleprerendered HTMLactions.subscribe()from the client scriptaction handlerzod, then fetchstatus linesame pagearticleplain form post/newsletterprerender = falseaction handlersame coderesult pagegetActionResultFormData/_actions/subscribe{ data, error }POST?_actionresult
  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
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
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
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 on the form's Fields tab.

Build it

The action

src/actions/index.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 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
---
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
---
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 instead: it looks the address up first, so a confirmed reader who signs up again keeps their status.

src/pages/api/ikiform.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 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

HostWhat 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

ProblemWhat to do
The no-JavaScript post shows a 404 or a static pageThe 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 worksThe 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 variableastro:env checks the schema at build time. Set the variable on the host, or in .env locally.
402 plan_required on local testsThe 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 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.