# Build a newsletter signup form in TanStack Start

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

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup/tanstack-start

Write the signup logic once in a server module, then call it from two places: a `createServerFn` POST function that the form calls in the page, and a server route the same form posts to when JavaScript hasn't loaded. Both check the consent box and forward the signup to your form backend with a secret key. A second server route receives the backend's signed webhook and creates the subscription in beehiiv.

The [main guide's TanStack Start tab](https://ikiform.com/guides/newsletter-signup.md#form) is one server function and one component. That covers readers whose JavaScript has loaded. This page adds what a site you share widely needs: a path for the reader who submits before hydration, a results page for that path, and a webhook route that passes real signups to beehiiv. Product setup is in the [server-side docs guide](https://ikiform.com/docs/guides/server.md).

## What TanStack Start changes

A server function is called through an RPC request the client makes, so a form that only knows `onSubmit` does nothing until the bundle runs. TanStack's docs mention a `.url` property for posting a form to a server function without JavaScript, but don't document how that response is rendered. A server route is plainer: it takes a normal form POST and returns a normal redirect. So the form gets a real `action` as well as its `onSubmit`:

Diagram: How one TanStack Start form reaches the same signup code with or without JavaScript

1. The reader submits the form. Whether JavaScript has loaded decides the path.
2. Hydrated: onSubmit stops the post and calls the server function with the form's data.
3. The server function runs forwardSignup, which checks consent and posts to Ikiform, and returns the outcome to the component.
4. Not hydrated: the browser posts to the /api/newsletter server route, which runs the same forwardSignup.
5. The route answers 303 with a status or an error code, and the /newsletter page shows the matching message.

forwardSignup is the only code that talks to Ikiform, so both paths validate and report errors the same way.

## Project setup

`Project tree`:

```text
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env
```

Server code reads secrets from `process.env`. Never give them a `VITE_` prefix: Vite puts `VITE_` variables into the client bundle. 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.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
```

## Build it

### The signup module

`src/server/newsletter.ts`:

```ts
export interface Signup {
  email: string;
  first_name?: string;
  consent: boolean;
  source: string;
  _gotcha: string;
}

export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };

const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);

export function readSignup(form: FormData): Signup {
  const source = String(form.get("source") ?? "");
  return {
    email: String(form.get("email") ?? "").trim(),
    first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
    consent: form.get("consent") === "true",
    source: PLACEMENTS.has(source) ? source : "footer",
    _gotcha: String(form.get("_gotcha") ?? ""),
  };
}

export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
  if (!signup.consent) {
    return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
  }
  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 ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...signup, consent: true }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true };
  const email = result?.error?.fields?.email?.[0];
  if (email) return { ok: false, field: "email", message: email };
  return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}
```

- `readSignup` turns `FormData` into a plain object. Both entry points use it, so a field name lives in one place.
- `source` must be one of three placements, so a crafted post can't invent new ones.
- 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.
- Every failure returns an `Outcome` instead of throwing, so the component and the route can both show it.

> 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 server function and the form

`src/components/NewsletterForm.tsx`:

```tsx
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";

const subscribe = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return readSignup(data);
  })
  .handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));

export function NewsletterForm({ source = "footer" }: { source?: string }) {
  const send = useServerFn(subscribe);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setOutcome(await send({ data }));
    } catch {
      setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = outcome?.ok === false ? outcome : null;

  return (
    <form method="post" action="/api/newsletter" onSubmit={onSubmit}>
      <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={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
```

- `.validator` receives whatever the client sent. Checking for `FormData` there means a crafted JSON call fails before the handler runs.
- `getRequestHeader` comes from `@tanstack/react-start/server` and reads the request that carried the RPC call, which the browser sent with your page's origin.
- The `FormData` is built before the first `await`, while `event.currentTarget` still points at the form.
- `useServerFn` lets the call follow redirects and errors through the router, which matters if you later throw `redirect()` from the handler.

### The no-JavaScript route and page

`src/routes/api/newsletter.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";

export const Route = createFileRoute("/api/newsletter")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const signup = readSignup(await request.formData());
        const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
        const target = new URL("/newsletter", request.url);
        target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
        return Response.redirect(target, 303);
      },
    },
  },
});
```

`src/routes/newsletter.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";

const MESSAGES = {
  consent: "Tick the box so we know you want the emails.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

export const Route = createFileRoute("/newsletter")({
  validateSearch: (search: Record<string, unknown>) => ({
    status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
    error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
  }),
  component: NewsletterPage,
});

function NewsletterPage() {
  const { status, error } = Route.useSearch();
  if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  return (
    <main>
      <h1>The newsletter</h1>
      {error ? <p role="alert">{MESSAGES[error]}</p> : null}
      <NewsletterForm source="newsletter-page" />
    </main>
  );
}
```

The route redirects with `303`, so a reload of the result page doesn't post the form again. It passes an error code, never Ikiform's message, and `validateSearch` drops anything outside the three codes, so nobody can put their own text on your page through a link.

## Forward subscribers to beehiiv

This server route receives Ikiform's signed webhook and creates the beehiiv subscription with `double_opt_override: "on"`, so beehiiv emails the confirmation. It reads the body with `request.text()` before anything else, because the signature covers the exact bytes. The [beehiiv guide](https://ikiform.com/guides/newsletter-signup/beehiiv.md) adds the first name, the referrer and beehiiv's rate limit headers.

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

```ts
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const Route = createFileRoute("/api/ikiform")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        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, consent, source } = event.data.fields;
        if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

        const answer = await fetch(
          `https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
          {
            method: "POST",
            headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
            body: JSON.stringify({
              email,
              double_opt_override: "on",
              reactivate_existing: false,
              utm_source: typeof source === "string" ? source : "website",
              utm_medium: "signup_form",
            }),
          },
        );
        if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
        if (!answer.ok) return new Response(`beehiiv 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 into `IKIFORM_WEBHOOK_SECRET`.

## Honeypot and the human check

The `_gotcha` input travels with the signup, and Ikiform stores a filled one as spam with a success answer and no webhook, so beehiiv never sees it. The secret key skips Ikiform's Turnstile check. If bots get past the honeypot and the required box, verify a Turnstile token in `forwardSignup`, as the [Turnstile guide](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md) shows, so both entry points get it.

## Rendering

Start renders routes on the server by default, so the form's HTML, `action` and all, is in the first response. If you prerender pages as static files, the form still works on them: the server function and both server routes run on demand on the host.

## Deploy

| Host | What to do |
| --- | --- |
| Vercel | Add Nitro: install `nitro` and put `nitro()` from `nitro/vite` in the Vite plugins. Set the four variables under **Environment Variables**. |
| Netlify | Add `@netlify/vite-plugin-tanstack-start` and put `netlify()` in the Vite plugins. Set the variables in the site's environment settings. |
| Cloudflare | Add `@cloudflare/vite-plugin` first in the plugins, with a `wrangler.jsonc` that turns on `nodejs_compat`. With that flag and a recent compatibility date, Workers expose variables and secrets on `process.env`. Use a custom domain for the webhook URL; 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 both paths: once normally, and once with JavaScript disabled in the dev tools. Ikiform won't deliver webhooks to `localhost`, so test `/api/ikiform` through a tunnel with an HTTPS URL.

## Check it works

1. Run the dev server and subscribe from the footer. The form is replaced with the check-your-inbox line and the URL doesn't change.
2. Disable JavaScript and subscribe again. You land on `/newsletter?status=subscribed`.
3. With JavaScript still off, submit with `required` removed and the box unticked. You land on `/newsletter?error=consent` with the consent message.
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 beehiiv. The subscription is there, pending until you confirm, and the confirmation email is in your inbox.

## Gotchas

| Problem | What to do |
| --- | --- |
| The secret key is `undefined` in production | The host didn't get the variable, or it was read in code that runs in the browser. Read secrets only inside server functions and server routes. |
| A JSON error page after submitting without JavaScript | The form's `action` points at the server function instead of the server route. Post to `/api/newsletter`. |
| `402 plan_required` on local tests | The post reached Ikiform without a `localhost` origin. Check that `forwardSignup` receives the origin from both callers. |
| Webhook deliveries fail with `400` | The secret doesn't match the endpoint's, or something parsed the body before `request.text()`. Read the raw text first. |
| 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

### Do TanStack Start server functions work without JavaScript?

TanStack's docs mention a .url property for posting a form to a server function, but a server route is the simpler fallback: it takes a plain form POST and redirects. This page uses both, with one shared module behind them.

### Where do secrets go in TanStack Start?

In server-only environment variables read with process.env inside server functions and server routes. Variables with the VITE_ prefix end up in the browser bundle.

### Why not add the subscriber to beehiiv inside the server function?

Ikiform answers spam with the same success as a real signup. Its webhook is only sent for signups that aren't spam, so creating the subscription from the webhook keeps bots off your list.

### 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.
- [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md): Connect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- [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.
- [React Router newsletter form](https://ikiform.com/guides/newsletter-signup/react-router.md): A React Router footer newsletter form: useFetcher posts to a /newsletter route that also works without JavaScript, and a resource route feeds MailerLite.
- [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.
