Skip to content
HomePagesHomePages template kit

Gate content behind the lead form

Hold back a section's content until the visitor has submitted your lead form.

Hold back a section’s content until the visitor has submitted your lead form — the gallery opens once they have introduced themselves.

A published page is a static document on a CDN, and the site prefetches its own other pages so navigation feels instant. So the bytes are already in the visitor’s browser before any gate runs: an island’s props travel in the page as JSON, and anyone who opens devtools can read them.

That makes this a gate over what a visitor sees, not over what they can fetch. It is the right tool for shaping a visit — see the gallery after you introduce yourself — and the wrong tool for anything that must stay secret. Nothing on a published page can be kept from a determined reader.

The unlock is a key in localStorage, written by your form island when the submission is accepted:

const result = await submitLead({ email, name });
if (result.ok) {
localStorage.setItem("unlocked:gallery", "1");
}

Branch on whether the submission was accepted, and never on whether it was simulated. Wherever there is no site config — the editor canvas, template-kit dev, any local preview — submitLead sends nothing and reports the submission as simulated (Add a lead form), so a gate keyed on a real send is one nobody can open on those surfaces: the visitor submits the form and stays locked out, and if the gated page also redirects locked visitors back to the form the two make a loop with no way through. Withholding the key buys no secrecy either — this gate is a localStorage convenience, not a security control, so refusing to write it protects nothing. Where a simulated submission deserves to be visible, say so in the confirmation copy (Add a lead form) and leave the unlock alone.

Storage is scoped to the origin, and a published site is its own origin — so the key you write is readable by that site’s pages and by nothing else.

The gated content belongs to an island, and the island renders nothing until it has checked the key. Withholding beats hiding: content that was never rendered cannot flash on a slow connection, and there is no CSS for a reader to defeat.

// sections/gallery/components/GatedGallery.tsx — a new island
"use client";
import type { IslandEditor } from "@homepages/template-kit";
import { useEditor } from "@homepages/template-kit";
import { type ReactNode, useEffect, useState } from "react";
export default function GatedGallery({ photos }: { photos: string[] }): ReactNode {
const { inCanvas } = useEditor();
const [unlocked, setUnlocked] = useState(false);
useEffect(() => {
setUnlocked(localStorage.getItem("unlocked:gallery") === "1");
}, []);
if (!inCanvas && !unlocked) {
return <p>Share your details above to see the full gallery.</p>;
}
return (
<ul>
{photos.map((src) => (
<li key={src}>
<img src={src} alt="" />
</li>
))}
</ul>
);
}
// Live, so the canvas hydrates it and the gate can stand down there.
export const editor = { live: true } satisfies IslandEditor;

The unlocked state starts out false so that the server render and the first browser render agree — localStorage does not exist during the server render, and reading it anywhere but an effect would make the two disagree and break hydration. The effect then flips it, on the client only.

The island receives photos as an ordinary prop; nothing about the gate changes how the section passes its content in. Render it from Renderer.tsx like any other component.

!inCanvas is not optional. Without it, the site’s owner opens the page in the editor, the gate finds no key, and the section renders its locked state — so the content they came to edit is not on the screen and there is no way to reach it.

useEditor() reports inCanvas, and editor = { live: true } is what makes the island hydrate in the canvas at all; a live: false island stays as its server-rendered output there, which for this island is the locked state. The two go together. See Islands for the hook and the pattern.

What inCanvas holds back here is a render, and that is the limit of what it can hold back. The bridge it reads is installed by the host, so an island whose first render lands before that install reads inCanvas: false inside a canvas — a render that will run again corrects itself; a step that cannot be taken back does not. Gating a whole page is that case.

A section can only withhold its own markup, so a page is fully gated only when every section on it withholds. In practice that means keeping the gated page’s content in one section, and sending a locked visitor away from it.

A redirect inside the canvas navigates the editor away from the page being edited, and that is not something inCanvas can be trusted to prevent — see Keep the canvas editable. Hold it out structurally: declare the island live: false, which is what a canvas consults before it hydrates anything, and let the island’s server-rendered output be the content.

export default function GatedPage({ photos }: { photos: string[] }): ReactNode {
const [locked, setLocked] = useState(false);
useEffect(() => {
if (localStorage.getItem("unlocked:gallery") === "1") return;
setLocked(true);
location.replace("/");
}, []);
return locked ? <LockedNotice /> : <Gallery photos={photos} />;
}
// NOT live: no canvas hydrates this island, so nothing above can run there and the
// author always gets the server-rendered gallery — the thing they came to edit.
export const editor = { live: false } satisfies IslandEditor;

The trade is deliberate: the content is in the page before the redirect fires, so a locked visitor sees it for a moment. That costs nothing this gate was protecting — the bytes travel in the page either way (What this gate is, and what it is not) — and it buys a canvas no timing accident can navigate away from. Note the other half of the bargain: a live: false island must server-render something (island-server-frame), and live: false beside serverFrame = false is the one illegal pairing, because it leaves a permanent blank hole in the canvas. Here that something is the content, which is exactly what an author needs to see.

Send them to the page that carries the form, and never put a redirecting gate on that page: a gate that redirects to itself is a loop. Under template-kit dev a root-relative path leaves the harness’s own /template/<template> mount, so expect that while authoring.

The locked state still renders an ordinary link to the form, and the form’s success state an ordinary link onward. A redirect is an optimization over a link, not a substitute for one: it fires only once the island has hydrated, and only when the destination is known — so a gate that offers nothing else strands every visitor it cannot redirect on a dead end.

The island file under Steps, its import and one render line in Renderer.tsx, and the three lines added to your form island’s submit handler. section.ts and fixtures.ts are untouched — a gate changes when content renders, not what the section declares.

Run the author loop, then check both states in dev:

  • /template/<template> — the plain render, where inCanvas is false and the gate is live. You should see the locked state.
  • /canvas/<template> — the canvas mirror. The content is always visible here, by design. If it is not: a withholding gate is missing its !inCanvas guard or is not live; a page gate is the reverse — it must not be live, and what shows here is its server render, so a locked state on this surface means the server render is the wrong branch.

Submitting the form writes the key while authoring exactly as it does live, so the round trip is walkable in dev. To re-lock, or to reach either state without the form, drive the key from the browser console:

localStorage.setItem("unlocked:gallery", "1"); // then reload — unlocked
localStorage.removeItem("unlocked:gallery"); // then reload — locked
  • Add a lead form — the form whose submission opens this gate, and the result shape the unlock branches on.
  • Islandseditor.live, useEditor(), and the inCanvas escape hatch this guide leans on.
  • Make a section interactive — the island shape this one follows.