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.
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:
| 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.
- With JavaScript, the footer script stops the normal post and calls the action with the form's data.
- The action validates the input, posts to Ikiform, and returns data or an error to the script, which updates the status line.
- Without JavaScript, the browser posts the form to the on-demand /newsletter page with the action in the query string.
- Astro runs the same action handler, and the page reads the outcome with getActionResult and shows it.
Project setup
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envInstall 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.
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" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Create 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
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 parseFormData. The checkbox goes through boolean coercion, so ticked istrueand unticked isfalse, whichrefineturns into the consent message.- Empty text inputs arrive as
null, so optional fields usenullish(), notoptional(). sourcefalls back tofooterfor any value outside the list, so a crafted post can't invent placements.- The
Originline passes the browser's origin on, because Ikiform recognises freelocalhosttests by that header and a server'sfetchsends none. - Ikiform's errors become an
ActionError. A429keeps its meaning asTOO_MANY_REQUESTS.
IKIFORM_SECRET_KEY in server-only environment variables.The footer component
---
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>queryStringis the documented way to build an action URL. Setting it on a/newsletterURL 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. isInputErrorseparates zod's per-field messages from Ikiform's refusals, which arrive as a plain message.
The on-demand page
---
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.
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
| 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
- Run
astro buildand check that your articles are still listed as prerendered in the output, with only/newsletterand/api/ikiformon demand. - Subscribe from an article's footer. The status line changes and the URL doesn't.
- Disable JavaScript and subscribe again. You land on
/newsletterwith the check-your-inbox line. - Submit with the box unticked from the dev tools. The consent message shows and nothing is stored.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- If you added a webhook, open the endpoint in Webhooks. The delivery shows
succeededwith your server's answer. - 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.