Newsletter signup form8 min read
Build a newsletter signup form in React Router
A React Router footer newsletter form: useFetcher posts to a /newsletter route that also works without JavaScript, and a resource route feeds MailerLite.
Put the action on a real /newsletter route with its own page, and post to it from a footer form built with useFetcher. With JavaScript, the fetcher submits in the background and the reader stays on the article; without it, the same form is a plain POST that lands on the /newsletter page with the result. The action checks consent and forwards the signup to your form backend, and a resource route turns its signed webhook into a MailerLite subscriber.
The main guide's React Router tab posts to a resource route with useFetcher. That works once JavaScript has loaded. This page fixes what happens before it does, stops a signup from reloading every loader on the page, and adds the webhook route for MailerLite. Product setup is in the server-side docs guide.
Why the action needs a page
React Router's docs describe fetcher.Form as a normal HTML form until JavaScript loads. A submit at that moment is a document POST to the form's action URL, and the browser shows whatever that route renders. A resource route renders nothing; it returns its data. So the reader who clicks early lands on raw JSON. Give the action a route with a default component and the same POST lands on a page:
- Before hydration, fetcher.Form is a plain form, so the browser posts to /newsletter and navigates there.
- If /newsletter is a resource route, the browser shows the action's JSON as the page.
- If /newsletter has a default component, React Router runs the action, then renders the page with the result as actionData.
- Once JavaScript has loaded, both versions behave the same: the fetcher posts in the background and the reader stays put.
Project setup
This uses framework mode with server rendering on, ssr: true in react-router.config.ts, which is the default.
app/
root.tsx
routes.ts
routes/
home.tsx
newsletter.tsx
api.ikiform.ts
components/newsletter-form.tsx
react-router.config.ts
.envimport { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("newsletter", "routes/newsletter.tsx"),
route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;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 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 /newsletter route
import { data } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
import type { Route } from "./+types/newsletter";
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
if (form.get("consent") !== "true") {
return data(
{ ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
{ status: 422 },
);
}
const source = String(form.get("source") ?? "");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
consent: true,
source: PLACEMENTS.includes(source) ? source : "footer",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true as const };
const email = result?.error?.fields?.email?.[0];
return data(
{
ok: false as const,
field: email ? "email" : "form",
message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
},
{ status: response?.status ?? 502 },
);
}
export function meta() {
return [{ title: "Newsletter" }];
}
export default function Newsletter({ actionData }: Route.ComponentProps) {
return (
<main>
<h1>The monthly newsletter</h1>
<NewsletterForm source="newsletter-page" initial={actionData} />
</main>
);
}- Errors are returned with
data()and a4xxstatus, not thrown, so they reach the form instead of the error boundary. - The consent check runs before the network call. A missing box costs nothing.
- The
Originline passes the browser's origin on, because Ikiform recognises freelocalhosttests by that header and a server'sfetchsends none. - The default component renders the same form, fed with
actionData, so the no-JavaScript result and the in-page result look alike.
IKIFORM_SECRET_KEY in server-only environment variables.The footer form
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";
interface Props {
source: "footer" | "post" | "newsletter-page";
initial?: Route.ComponentProps["actionData"];
}
export function NewsletterForm({ source, initial }: Props) {
const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
const result = fetcher.data ?? initial;
const busy = fetcher.state !== "idle";
if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
const error = result?.ok === false ? result : null;
return (
<fetcher.Form method="post" action="/newsletter">
<label>
Email
<input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "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={error?.field === "consent"} />
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 />
{error ? <p role="alert">{error.message}</p> : null}
<button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
</fetcher.Form>
);
}fetcher.dataholds the action's answer after a background submit;initialholds it after a document POST. Whichever exists wins.fetcher.stateissubmittingand thenloadingbefore it returns toidle, so the button stays disabled for both.- A
keyper placement gives the footer form and the/newsletterpage form separate state when both are on screen.
Mount it without reloading the page's data
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
if (formAction === "/newsletter") return false;
return defaultShouldRevalidate;
}
export default function App() {
return (
<>
<Outlet />
<footer>
<p>One email a month. Unsubscribe from any issue.</p>
<NewsletterForm source="footer" />
</footer>
</>
);
}After a fetcher submits to an action, React Router revalidates the loaders of every matched route by default. A newsletter signup changes nothing they load, so shouldRevalidate on the root skips that work when the submission went to /newsletter. Add the same export to any route whose loader is expensive. The form goes in the default App component rather than Layout, because Layout also wraps the error boundary.
Forward subscribers to MailerLite
Webhooks come from outside your app, so this one is a resource route: an action with no component, returning plain Response objects as React Router's docs suggest for external callers. 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 { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";
export async function action({ request }: Route.ActionArgs) {
let event;
try {
event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.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 ${process.env.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: [process.env.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 });
}Add https://your-site.com/api/ikiform under Webhooks, pick the newsletter form, and copy the whsec_ 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 MailerLite never sees it. The secret key skips Ikiform's Turnstile check, and every post now comes from your server's IP. If bots get through, verify a Turnstile token in the action before the fetch, as the Turnstile guide shows.
Rendering and prerendering
You can prerender articles with the prerender option while keeping ssr: true; the server still runs /newsletter and the webhook route on demand. SPA mode, ssr: false, doesn't work here: React Router's docs say actions are prohibited without a runtime server, and the build fails if a route has one. A fully static React Router site should post from the browser with @ikiform/sdk instead.
Deploy
| Host | What to do |
|---|---|
| Vercel | Deploys with zero configuration. Vercel recommends adding vercelPreset() from @vercel/react-router/vite to presets in react-router.config.ts. Set the variables under Environment Variables. |
| Netlify | Install @netlify/vite-plugin-react-router and add it to the Vite plugins; SSR runs as Netlify Functions. Set the variables in the site's environment settings. |
| Cloudflare | Start from the React Router template for Workers. Read secrets with import { env } from "cloudflare:workers" instead of process.env; React Router 8 no longer puts them on context.cloudflare.env. Use a custom domain for the webhook; Ikiform refuses workers.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 dev-server signup one of them. Test the early-click path by disabling JavaScript in the dev tools. Ikiform won't deliver webhooks to localhost; expose the dev server through a tunnel with an HTTPS URL to test /api/ikiform.
Check it works
- Run
react-router dev, open an article and subscribe from the footer. The check-your-inbox line appears and the URL doesn't change. - Open the network tab and subscribe again from a fresh page. There's one request to
/newsletterand no loader requests after it. - Disable JavaScript and subscribe. You land on
/newsletterwith the same line, not on JSON. - 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 build fails with an error about actions | ssr is false. Actions need a runtime server; turn SSR on and use prerender for the static pages. |
| Every signup reloads the page's data | Loaders revalidate after any action. Add shouldRevalidate that returns false when formAction is /newsletter. |
The secret key is undefined on Cloudflare | Workers don't fill process.env by default. Read the secret from env in cloudflare:workers in the action. |
402 plan_required on local tests | The post had no localhost origin. Check the Origin line in the action, and open the app 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
Yes. fetcher.Form renders a normal HTML form, so before hydration it posts and navigates to its action URL. Make that URL a route with a component, or the reader sees the action's raw data.
Not for a form people submit. Use a route with a default component so the no-JavaScript post shows a page. Keep resource routes for webhooks and other callers that aren't browsers.
Two reasons. The action gets the same success answer for spam as for a real signup, so it would add bots too. And a MailerLite error inside the action would show a failure for a signup Ikiform already stored, while a failed webhook delivery is retried.
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.