Skip to content
HomePagesHomePages template kit
Menu

Add a lead form

Recipe — collect a visitor's contact details and submit them to HomePages as a lead.

A lead form is an island — a "use client" component — that calls submitLead from @homepages/template-kit/browser. The kit owns the whole request: signing, the idempotency key, retry, and source attribution. You own the markup and what the visitor sees.

sections/contact/components/LeadForm.tsx:

"use client";
import { submitLead } from "@homepages/template-kit/browser";
import { useState } from "react";
type Status = "idle" | "sending" | "done" | "error";
export default function LeadForm() {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState("");
async function submit(): Promise<void> {
setStatus("sending");
const result = await submitLead({ email, name });
if (result.ok) {
setStatus("done");
return;
}
setError(result.message);
setStatus("error");
}
if (status === "done") return <p>Thank you — we'll be in touch.</p>;
return (
<form
data-tr-lead-form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<input
name="name"
value={name}
placeholder="Name"
onChange={(event) => setName(event.target.value)}
/>
<input
name="email"
type="email"
required
value={email}
placeholder="Email"
onChange={(event) => setEmail(event.target.value)}
/>
<button type="submit" disabled={status === "sending"}>
Submit
</button>
{status === "error" && <p role="alert">{error}</p>}
</form>
);
}
// Submitting a lead is not an editing interaction, so this island stays static
// server-rendered HTML in the editor canvas. It still hydrates on published pages.
export const editor = { live: false };

Two attributes are load-bearing:

  • data-tr-lead-form on the <form> — how HomePages detects that a visitor engaged with the form.
  • export const editor — required of every island; see Islands.

Only email is required. name, phone, and message are named because almost every form collects them. Anything else goes in extra:

await submitLead({
email,
name,
phone,
message,
extra: { interest: "3br", lead_intent: "potential-buyer" },
});

extra cannot overwrite the named fields or the source attribution. Precedence runs extra → named contact fields → attribution, last one winning.

You never add attribution yourself. The referring site, the utm_* parameters, and the page URL are captured automatically and reach the site owner’s lead inbox.

submitLead never throws. It returns a result you branch on:

const result = await submitLead({ email });
if (result.ok) {
// Sent. `result.simulated` is true when there was no site config — see below.
} else {
result.reason; // "missing-email" | "in-flight" | "too-large"
// | "unauthorized" | "rejected" | "network"
result.message; // a sentence you can show the visitor as-is
}

Show result.message rather than writing your own copy per reason — it already describes what the visitor should do.

A network error or a server error is retried for you, twice, with backoff, reusing one idempotency key — so a retry can never create a duplicate lead. A rejected request (a 4xx) is not retried, because retrying it cannot help.

A lead is only submittable from a published page, which is where the per-site config lives. In the editor canvas and in template-kit dev there is no config, so submitLead sends nothing and returns { ok: true, simulated: true }.

Your success UI is therefore exercised exactly as it will behave live, and the console tells you plainly that nothing was sent. If you want the success state to say so during authoring, branch on it:

if (result.ok && result.simulated) {
// preview only — no lead was created
}

submitLead is the supported path for leads. For any other signed request, the same entry exports the primitives underneath it — readSiteConfig() and signedIngestHeaders(config, body). See Islands.

Then run the author loop: edit, preview, check.