Newsletter signup form

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.

Preet SutharFounder, Ikiform

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:

What a reader without JavaScript sees after submitting the footer formaction in a resource routeaction in a UI routefooter formfetcher.Form, nothydrated/newsletteraction, nocomponentraw JSON{"ok":true}footer formfetcher.Form, nothydrated/newsletteraction + defaultcomponentresult pageactionDatarendereddocument POSTresponsedocument POSTrenderWhat a reader without JavaScript sees after submitting the footer formaction in a resource routeaction in a UI routefooter formfetcher.Form, not hydrated/newsletteraction, no componentraw JSON{"ok":true}footer formfetcher.Form, not hydrated/newsletteraction + default componentresult pageactionData rendereddocument POSTresponsedocument POSTrender
  1. Before hydration, fetcher.Form is a plain form, so the browser posts to /newsletter and navigates there.
  2. If /newsletter is a resource route, the browser shows the action's JSON as the page.
  3. If /newsletter has a default component, React Router runs the action, then renders the page with the result as actionData.
  4. Once JavaScript has loaded, both versions behave the same: the fetcher posts in the background and the reader stays put.
The fix is one default export. The action and the footer form stay the same.

Project setup

This uses framework mode with server rendering on, ssr: true in react-router.config.ts, which is the default.

Project tree
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
app/routes.ts
import { 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;
.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 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

app/routes/newsletter.tsx
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 a 4xx status, 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 Origin line passes the browser's origin on, because Ikiform recognises free localhost tests by that header and a server's fetch sends none.
  • The default component renders the same form, fed with actionData, so the no-JavaScript result and the in-page result look alike.
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 form

app/components/newsletter-form.tsx
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.data holds the action's answer after a background submit; initial holds it after a document POST. Whichever exists wins.
  • fetcher.state is submitting and then loading before it returns to idle, so the button stays disabled for both.
  • A key per placement gives the footer form and the /newsletter page form separate state when both are on screen.

Mount it without reloading the page's data

app/root.tsx
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.

app/routes/api.ikiform.ts
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

HostWhat to do
VercelDeploys 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.
NetlifyInstall @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.
CloudflareStart 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

  1. Run react-router dev, open an article and subscribe from the footer. The check-your-inbox line appears and the URL doesn't change.
  2. Open the network tab and subscribe again from a fresh page. There's one request to /newsletter and no loader requests after it.
  3. Disable JavaScript and subscribe. You land on /newsletter with the same line, not on JSON.
  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 MailerLite. The subscriber is in the group as unconfirmed, and the confirmation email is in your inbox.

Gotchas

ProblemWhat to do
The build fails with an error about actionsssr is false. Actions need a runtime server; turn SSR on and use prerender for the static pages.
Every signup reloads the page's dataLoaders revalidate after any action. Add shouldRevalidate that returns false when formAction is /newsletter.
The secret key is undefined on CloudflareWorkers don't fill process.env by default. Read the secret from env in cloudflare:workers in the action.
402 plan_required on local testsThe 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 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.