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

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

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](https://ikiform.com/guides/newsletter-signup.md#form) 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](https://ikiform.com/docs/guides/server.md).

## 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:

Diagram: What a reader without JavaScript sees after submitting the footer form

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`:

```text
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`:

```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`:

```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 Ikiform 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 /newsletter route

`app/routes/newsletter.tsx`:

```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](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 form

`app/components/newsletter-form.tsx`:

```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`:

```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](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.

`app/routes/api.ikiform.ts`:

```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](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md) 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

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

| 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

### Does useFetcher work without JavaScript?

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.

### Should the newsletter action be a resource route?

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.

### Why add subscribers to MailerLite from a webhook instead of the action?

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.

### 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.
- [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.
- [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.
- [TanStack Start newsletter form](https://ikiform.com/guides/newsletter-signup/tanstack-start.md): A TanStack Start newsletter form: a server function for in-page signups, a server route as the no-JavaScript fallback, and real signups sent to beehiiv.
- [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.
- [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.
