Skip to content
HomePagesHomePages template kit

Add a lead form

Collect a visitor's contact details and submit them to HomePages as a lead.

Collect a visitor’s contact details and submit them to HomePages as a lead.

  • A section to hold the form — Install scaffolds one, with Renderer.tsx alongside section.ts and fixtures.ts.
  • A components/ folder inside it for the island file — Organize a section’s files adds one.

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 declarations 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, and live is written out either way. false is the right answer here: submitting a lead is not an editing interaction, so the form has nothing to do in the canvas. 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.

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.

The island file under Steps is the whole change — render it from Renderer.tsx like any other component. section.ts and fixtures.ts are untouched: a lead form collects what a visitor types, so it declares no slot and needs no fixture value.

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:

const result = await submitLead({ email, name });
if (result.ok && result.simulated) {
// preview only — no lead was created
}

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

  • Make a section interactive — the island shape this one follows, and the marker question an island that renders a slot has to answer.
  • Islands — the props contract, the editor declaration, and the DOM contract a published page hydrates.