Newsletter signup form

Newsletter signup form8 min read

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.

Preet SutharFounder, Ikiform

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

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:

How one TanStack Start form reaches the same signup code with or without JavaScriptNewsletterFormaction="/api/newsletter"hydrated?onSubmitattachedserverfunctionRPC,samepageforwardSignup()consent,thenIkiformoutcomerenderedin place/api/newsletterserverroute,formPOST303 to/newsletter?statusor?errorsubmityesdataOutcomenot yetHow one TanStack Start form reaches the same signup code with or without JavaScriptNewsletterFormaction="/api/newsletter"hydrated?onSubmitattachedserver functionRPC, same pageforwardSignup()consent, then Ikiformoutcomerendered in place/api/newsletterserver route,form POST303 to/newsletter?status or?errorsubmityesdataOutcomenot yet
  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
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 on the form's Fields tab.

.env
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
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 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
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
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
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 adds the first name, the referrer and beehiiv's rate limit headers.

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

HostWhat to do
VercelAdd Nitro: install nitro and put nitro() from nitro/vite in the Vite plugins. Set the four variables under Environment Variables.
NetlifyAdd @netlify/vite-plugin-tanstack-start and put netlify() in the Vite plugins. Set the variables in the site's environment settings.
CloudflareAdd @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

ProblemWhat to do
The secret key is undefined in productionThe 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 JavaScriptThe form's action points at the server function instead of the server route. Post to /api/newsletter.
402 plan_required on local testsThe post reached Ikiform without a localhost origin. Check that forwardSignup receives the origin from both callers.
Webhook deliveries fail with 400The 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 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.