Waitlist for a new SaaS6 min read
How to add a waitlist to a Framer site
Two ways to collect waitlist signups on Framer: Framer's form with a webhook to Ikiform, or a code component that keeps per-visitor limits and spam checks.
Framer forms can't post to an outside URL, but they can send each submission to a webhook, so the no-code route is Framer's form with your Ikiform endpoint as the webhook. For a public launch, use a small code component instead: the browser posts directly, so Ikiform's per-visitor rate limit, allowed domains and honeypot all keep working.
Framer is where a lot of pre-launch pages are built, because the page itself takes an afternoon. The waitlist form is where it gets less obvious. Framer's form block can't change its action, so you choose between two routes that behave differently once real traffic arrives.
What's different on Framer
- No custom action. A Framer form sends to Framer's destinations: its own storage, email, or a Webhook. A webhook is the only way to reach an outside backend without code.
- Webhooks come from Framer's servers, not the visitor. Ikiform sees Framer's IP addresses and no
Originheader. That turns off three protections: allowed domains, the per-visitor rate limit and the human check. - Retries. Framer treats anything but a
2xxas a failure and retries up to five times. It doesn't follow redirects. Ikiform answers a JSON post with201, so a stored signup is a success the first time. - Code components are real React. They run in the visitor's browser on the published site, so a form inside one posts like any other web page.
- With the webhook route, the visitor submits Framer's form and Framer's servers forward the fields to Ikiform as JSON.
- Ikiform sees Framer's servers as the sender, so it can't tell visitors apart or check which site the form is on.
- With the code component, the visitor's browser posts straight to Ikiform.
- Ikiform sees each visitor's IP and your domain, so every protection applies.
Pick a route
| Item | Framer form with a webhook | Code component |
|---|---|---|
| Setup | Two fields in the right sidebar | One code file, pasted once |
| Styling | Framer's form styles and states | Inline styles you edit, or your own classes |
| Per-visitor rate limit | No. All posts share Framer's IPs | Yes, 5 a minute each |
| Allowed domains | Leave empty | Set to your Framer and custom domains |
| Honeypot | No | Yes |
| Human check | Off | Off, because it posts with fetch |
| Source tracking | None | ?ref= from the URL, or a default per instance |
The webhook route is fine for a page you share with a few hundred people. If you're about to post the page somewhere big, use the code component: a burst of bot signups through Framer's servers can only be stopped by spam scoring.
Route 1: Framer's form with a webhook
- Add a form to the page with one Email input. In the input's settings, set its name to
email. Framer sends each input's name as the JSON key, and Ikiform usesemailfor reply-to and the disposable-domain check. - Select the form. Next to Send To, choose Add… › Webhook and paste your endpoint,
https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx. The signing secret is optional; Ikiform doesn't check Framer's signature. - In Ikiform, leave Allowed domains empty and the spam check off. With either one on, every Framer post is flagged or refused, because it carries no origin and can't run a browser check.
- Set the rate limit to Relaxed, or Custom on launch day. Standard counts all of Framer's posts as one visitor, and the whole form is capped at 300 a minute.
- Style the form's success state in Framer. It shows when Ikiform answers
201.
Route 2: a code component
In the Assets panel, open Code, create a code file named WaitlistSignup, and paste this. Drag it onto the page and set Form ID in the properties panel.
import { addPropertyControls, ControlType } from "framer";
import { type CSSProperties, type FormEvent, useRef, useState } from "react";
const box: CSSProperties = {
font: "inherit",
padding: "12px 14px",
borderRadius: 10,
border: "1px solid rgba(0, 0, 0, 0.15)",
};
interface Props {
formId: string;
buttonLabel: string;
joinedText: string;
source: string;
}
export default function WaitlistSignup({ formId, buttonLabel, joinedText, source }: Props) {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
const key = useRef("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
const ref = new URLSearchParams(window.location.search).get("ref");
body.set("ref", (ref || source).slice(0, 40));
key.current ||= crypto.randomUUID();
setStatus("sending");
setError("");
try {
const response = await fetch(`https://ikiform.com/f/${formId}`, {
method: "POST",
headers: { Accept: "application/json", "Idempotency-Key": key.current },
body,
});
const result = await response.json();
if (result.ok) return setStatus("joined");
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status" style={{ margin: 0 }}>{joinedText}</p>;
return (
<form onSubmit={onSubmit} style={{ display: "flex", flexWrap: "wrap", gap: 8, width: "100%" }}>
<input
name="email"
type="email"
placeholder="you@company.com"
aria-label="Email"
autoComplete="email"
required
style={{ ...box, flex: "1 1 220px" }}
/>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button type="submit" disabled={status === "sending"} style={{ ...box, cursor: "pointer" }}>
{status === "sending" ? "Joining" : buttonLabel}
</button>
{error ? <p role="alert" style={{ flexBasis: "100%", margin: 0 }}>{error}</p> : null}
</form>
);
}
addPropertyControls(WaitlistSignup, {
formId: { type: ControlType.String, title: "Form ID", defaultValue: "frm_xxxxxxxxxxxxxxxx" },
buttonLabel: { type: ControlType.String, title: "Button", defaultValue: "Join the waitlist" },
joinedText: { type: ControlType.String, title: "Joined text", defaultValue: "You're on the list." },
source: { type: ControlType.String, title: "Source", defaultValue: "framer" },
});- The properties panel exposes the button label, the joined text and a default Source. Drop one instance in the hero with
heroand another in the footer withfooter, and you'll see which converts. - A
?ref=in the page URL wins over the default, so campaign links tag themselves. - The idempotency key is made on the first submit and reused on retries from the same visit, so a double tap on a phone stores one signup.
windowis only read inside the submit handler. Framer renders components on the server first, and touchingwindowduring render would break that.
Ikiform settings for the component
Add these on the form's Fields tab, so the email is required and ref is allowed:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}- Under Allowed domains, add
your-project.framer.appand your custom domain. Never addframer.appon its own; every Framer site would match. - Keep the spam check off. The component posts with
fetchand doesn't fetch a token, so a form with the check on answers403 challenge_required. - Keep Standard rate limits. Each visitor is counted on their own now.
Check it works
- Publish the site. Webhooks and code components only post from the published site, not the editor preview.
- Submit the form on your
framer.appdomain and again on your custom domain. - Open Submissions in the dashboard. The new submission is there with every field you sent.
- For the component, open the page with
?ref=testand check the signup showsrefastest. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield. - Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
Gotchas
| Problem | What to do |
|---|---|
Every webhook signup is under spam with origin_mismatch | Allowed domains is set, and Framer's server posts have no origin. Clear it for this form, or switch to the code component. |
| Framer reports failed webhook deliveries | Ikiform refused the post: 403 challenge_required with the spam check on, or 422 because an input name isn't email. Fix the cause; retries fail the same way. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
The component shows Unknown field "ref" | The field definition doesn't list ref. Add it as in the definition above. |
403 origin_not_allowed, or every post lands in spam | The page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed. |
The full Framer setup, including the contact-form version, is in the Framer docs guide. Exporting the list and sending the launch email are in the main guide.
Questions
Not as a form action. Framer forms send to Framer's own destinations, and one of them is a webhook to any https URL. Use that for Ikiform, or render your own form with a code component.
With the webhook route, Framer posts from its servers without an Origin header, so a form with Allowed domains set marks every post as origin_mismatch. Leave Allowed domains empty for webhook forms.
Framer says form submissions are unlimited, so webhook signups don't run into a Framer cap. The code component route doesn't use Framer's forms at all.
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.