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.
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:
- The reader submits the form. Whether JavaScript has loaded decides the path.
- Hydrated: onSubmit stops the post and calls the server function with the form's data.
- The server function runs forwardSignup, which checks consent and posts to Ikiform, and returns the outcome to the component.
- Not hydrated: the browser posts to the /api/newsletter server route, which runs the same forwardSignup.
- The route answers 303 with a status or an error code, and the /newsletter page shows the matching message.
Project setup
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.envServer 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.
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-000000000000Build it
The signup module
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." };
}readSignupturnsFormDatainto a plain object. Both entry points use it, so a field name lives in one place.sourcemust be one of three placements, so a crafted post can't invent new ones.- The
Originline passes the browser's origin on, because Ikiform recognises freelocalhosttests by that header and a server'sfetchsends none. - Every failure returns an
Outcomeinstead of throwing, so the component and the route can both show it.
IKIFORM_SECRET_KEY in server-only environment variables.The server function and the form
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>
);
}.validatorreceives whatever the client sent. Checking forFormDatathere means a crafted JSON call fails before the handler runs.getRequestHeadercomes from@tanstack/react-start/serverand reads the request that carried the RPC call, which the browser sent with your page's origin.- The
FormDatais built before the firstawait, whileevent.currentTargetstill points at the form. useServerFnlets the call follow redirects and errors through the router, which matters if you later throwredirect()from the handler.
The no-JavaScript route and page
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);
},
},
},
});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.
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
| 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
- 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.
- Disable JavaScript and subscribe again. You land on
/newsletter?status=subscribed. - With JavaScript still off, submit with
requiredremoved and the box unticked. You land on/newsletter?error=consentwith the consent message. - 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 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
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.
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.
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.
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.