An Ebook Download Landing Page Is Two Systems, Not One
The page converts; the download has to not leak. A 60-second signed URL, a private bucket, and the honeypot most ebook-page advice leaves out.
An ebook download landing page is two systems wearing one name. The page has one job — one offer, one form, no navigation — and every listicle covers it. The download has a different job: serve the file to the person who just converted and to nobody else. Most advice stops at the form and leaves the PDF on a guessable public URL, which is where the leak happens.
This storefront runs the second half for real. Every file a buyer downloads comes out of a private bucket behind an authorization check and a 60-second signed URL, and the same shape works for a free ebook behind an email form. Here is what each half actually needs.
The page half, audited against 66 real landing templates
The standard advice is to strip the page down to one form. That is genuinely what a lead-magnet page wants, and it is also the part where template libraries and landing-page builders already agree, so there is not much to add — except a number.
This catalog holds 111 products, 66 of them landing pages. When they were audited for where the email capture point sits, only 14 of the 66 were single-purpose squeeze pages; the rest carry a hero, sections, and a capture point that appears more than once. That audit is the email-signup post's subject, and the conclusion transfers directly: a dedicated ebook page is the minority shape even among templates sold for lead capture, which is why you usually build it rather than find it. The same is true of any single-offer page written for one audience — a real-estate landing page is the worked example in this catalog.
What the page needs is short and uncontroversial:
| Element | Why it earns its place |
|---|---|
| A specific promise in the headline | "47-page guide to X" converts better than "Free ebook" because it is falsifiable |
| A cover image | The only thing that makes an abstract file feel like an object |
| One form, one field | Every added field is an added reason to leave |
| The delivery promise, stated | "Sent to your inbox" and "download starts now" are different products; say which |
| No navigation | A nav bar is a list of ways to not convert |
| A honeypot | Because the form is public and bots find it |
That last row is the one usually left out, and it is the cheapest.
The form half: one Server Action and a hidden field
This site's capture point is a waitlist form, not an ebook gate, but it is the same mechanism — email in, record created, one conversion event out. The honeypot is the interesting part:
// src/components/molecules/NewsletterForm.tsx
<div style={{ display: "none" }} aria-hidden="true">
<label htmlFor="company">Company</label>
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
</div>
Three attributes make it work and keep it honest: display: none hides it from sighted users, aria-hidden hides it from screen readers, and tabIndex={-1} keeps it out of the tab order. A bot that fills every input fills this one; a human never sees it. The Server Action drops any submission where it is non-empty.
The other detail worth copying is where the analytics event fires:
const [state, formAction, pending] = useActionState(subscribeToWaitlist, null);
useEffect(() => {
if (state?.ok) track("waitlist_joined");
}, [state]);
useActionState returns a fresh state object per submission, so this effect runs once per successful result — never on the error branch, never on a re-render. An ebook page instrumented by putting track() in the submit handler reports a conversion for every click, including the ones that failed validation, and the number it produces is the one you then optimise against. The waitlist post has the full action.
The download half, which is where pages leak
Now the part the listicles skip. Once the form succeeds, something has to hand over a file. The four options, in ascending order of how long they survive:
| Approach | What goes wrong |
|---|---|
Link straight to /ebook.pdf in public/ | The URL is permanent, guessable and shareable; the form is decoration |
| Redirect to the file after submit | Same URL, one extra hop — anyone who has it once has it forever |
| Email a link | Better, and still a permanent URL unless the link itself expires |
| Issue a short-lived signed URL per request | The link is useless minutes later, and each issue is attributable |
This repo does the fourth, and the TTL is not generous:
// src/lib/download.ts
export const SIGNED_URL_TTL_SECONDS = 60;
Sixty seconds is enough for a browser to start the transfer and too short for the URL to be worth pasting anywhere. The object lives in a private Supabase Storage bucket, so there is no unsigned path to it at all — not an obscure one, not a long random one, none.
The order the checks run in
The route that issues the URL is a Route Handler, and the sequence is deliberate:
// src/app/api/download/route.ts
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const supabase = await createClient(); // anon key, RLS-enforced, request cookies
const admin = createAdminClient(); // service role, for signing only
const deps: DownloadDeps = {
async getSession() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
return { userId: user.id, emailVerified: Boolean(user.email_confirmed_at) };
},
// …
};
}
Authenticate, then check the email is verified, then authorize, then rate-limit, then record, and only then sign. Ordering matters because each step can deny and the cheap denials should come first; the gated-downloads post works through why each check sits where it does and which ones fail closed.
Two details transfer to an ebook page even though this route serves paid products. First, emailVerified comes from getUser() rather than getClaims(), because email_confirmed_at is only on the user record — and for a lead magnet, "did this address actually confirm" is the entire point of the exercise. Second, the authorization decision is a pure function:
// src/lib/entitlements.ts
export function authorizeDownload(slots: Slot[], target: DownloadTarget): boolean {
return slots.some((s) => slotCovers(s, target));
}
No database import, no await. For a free ebook the slot list is simply "confirmed subscribers get the one file", which is the same function with a one-line rule — and it stays testable without a database either way.
Rate limiting, because a lead magnet is a public endpoint
The moment a URL issuer is reachable by anyone who filled a form, it needs a budget. This repo's is derived rather than fixed:
// src/lib/download.ts
export const RATE_LIMIT_MIN_PER_HOUR = 30;
export const RATE_LIMIT_PULLS_PER_ENTITLED_EDITION = 3;
The floor is 30 pulls an hour, and the actual limit scales with how many files the caller is entitled to — three pulls of each. A single-file lead magnet lands on the floor, which is exactly right: 30 attempts an hour absorbs a flaky connection and a genuine retry, and stops a script cold. The limiter itself is enforced in SQL (0007_atomic_download_rate_limit.sql) rather than in application code, because a count-then-insert in TypeScript has a race between the count and the insert that two concurrent requests will find.
Every issue is also written to a download_events row, which is what makes a specific account answerable later. For an ebook, that log is also your only honest measure of how many people who converted actually opened the thing.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| The ebook URL circulates on social media | File served from public/, so its URL is permanent | Private bucket plus a short-lived signed URL per request |
| Conversion rate looks great, list growth doesn't | track() fires on submit rather than on success | Fire on the successful result only, as the effect above does |
| Bot signups fill the list within a day | No honeypot and no validation | Add the hidden field, and reject non-empty submissions server-side |
| Download works locally, 404s in production | The bucket exists, the object was never uploaded | Uploading files is a separate step from creating the bucket |
| Signed URL expires before the download starts | TTL tuned for the ideal case | 60s is a floor that works; measure before going lower |
| Every visitor can pull the file repeatedly | No per-account budget on the issuing endpoint | Rate-limit the issuer, and enforce it in the database |
| Emails bounce and the file is never delivered | Delivery promised by email, address never verified | Gate on a confirmed address, exactly what email_confirmed_at is for |
Frequently asked questions
Should the ebook download start immediately or arrive by email?
Immediately if you want the highest conversion rate, by email if you want a verified address. This repo gates on email_confirmed_at, which is the second choice — because an unverified address on a list is a number, not a lead.
Where should the PDF actually live?
Anywhere that has no public URL. A private Supabase Storage bucket, S3 with no public policy, or R2 with signed access all work. The requirement is that no path to the object exists without a signature, which rules out public/ in your app.
How long should a signed URL live? Long enough for a transfer to begin. Sixty seconds is this codebase's value and it has not needed raising; anything measured in hours is a permanent link with extra steps.
Do I need a separate page, or can the offer sit on my homepage? Both work, and the audit above says most real templates do the second. A separate page is worth building when you are sending paid or campaign traffic to it, because then the page's only job is that one conversion and you can measure it cleanly.
Is a honeypot enough spam protection? For a lead magnet, usually — it costs one hidden input and catches the indiscriminate form-fillers. If you start seeing targeted submissions, the next step is a rate limit on the action itself rather than a CAPTCHA, which costs every human visitor something.
Templates in this post
ASoc Cover is an insurance marketing site built around bundled cover, instant quotes and fast claims. ASoc Echo is an AI chatbot / support SaaS landing page with a live widget preview and a flow-builder pitch. ASoc Edge is an applied-AI agency marketing site with a control-room dashboard, integrations and team profiles. All three carry the capture-point-plus-offer structure an ebook page needs; the delivery half above is what you add behind it.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
