Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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:

ElementWhy 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 imageThe only thing that makes an abstract file feel like an object
One form, one fieldEvery 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 navigationA nav bar is a list of ways to not convert
A honeypotBecause 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:

ApproachWhat 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 submitSame URL, one extra hop — anyone who has it once has it forever
Email a linkBetter, and still a permanent URL unless the link itself expires
Issue a short-lived signed URL per requestThe 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

SymptomCauseFix
The ebook URL circulates on social mediaFile served from public/, so its URL is permanentPrivate bucket plus a short-lived signed URL per request
Conversion rate looks great, list growth doesn'ttrack() fires on submit rather than on successFire on the successful result only, as the effect above does
Bot signups fill the list within a dayNo honeypot and no validationAdd the hidden field, and reject non-empty submissions server-side
Download works locally, 404s in productionThe bucket exists, the object was never uploadedUploading files is a separate step from creating the bucket
Signed URL expires before the download startsTTL tuned for the ideal case60s is a floor that works; measure before going lower
Every visitor can pull the file repeatedlyNo per-account budget on the issuing endpointRate-limit the issuer, and enforce it in the database
Emails bounce and the file is never deliveredDelivery promised by email, address never verifiedGate 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.

Keep reading

Tutorial9 min read

E-Commerce in React: The Storefront With No Shopping Cart

This storefront sells 113 products with zero cart code: one Server Action resolves a fixed-tier checkout, and a slot-based entitlement engine replaces the order.

Read more
Tutorial10 min read

Email Signup Landing Pages: Where the Capture Point Actually Goes

Squeeze-page advice says strip the page to one form. Only 14 of 66 landing templates in this catalog do — audited against this storefront's own single, sitewide capture point.

Read more
Tutorial8 min read

forEach in TypeScript: 4 Uses Against 149 Maps

A census of a real TypeScript codebase: 4 forEach calls, 149 .map() and 137 for...of. What the four have in common, and the async trap that explains the ratio.

Read more