Skip to main content
ASoc
Comparison

Gumroad Alternative: The Six Jobs You Take Back In-House

A hosted storefront does six jobs. Another one changes the fee; owning your storefront takes them back as code — 1,607 lines here, file by file.

The ASoc Team11 min read

A hosted storefront does six jobs for you: the product page, the checkout, the tax registration, the entitlement record, the file delivery and the receipt. Moving to another hosted platform keeps all six and changes the fee. Owning your storefront takes them back as code — 1,607 lines of it here, most of it in the two jobs nobody advertises.

We sell digital templates from a Next.js storefront we wrote ourselves, with a merchant of record handling the money. This post is the inventory: file by file, what each of those six jobs cost us, and which of them you genuinely should not take back.

"Gumroad alternative" is asked by two people with almost nothing in common.

The first wants a cheaper hosted platform — the same product pages, the same checkout, the same file delivery, a different logo and a different percentage. That question is answered by the comparison listicles, and it is answered honestly: pick on fees, payout schedule, and whether the platform is the seller of record in your buyers' countries. Check the current numbers on each vendor's own pricing page the week you decide; every fee figure published in a blog post, including ours, is a snapshot of a number the vendor can change unilaterally.

The second wants their own storefront: their domain, their SEO, their design, their upsells — and is trying to find out what that actually costs. That question is barely answered anywhere, because the answer is an engineering inventory rather than a table of percentages. That is the one this post covers.

The six jobs, and what each cost us

JobWhat a hosted storefront doesWhat owning it cost here
Product page + SEOOne page per product on their domain, their templatesrc/app/templates/[slug] — 111 prerendered pages on our domain, our markup, our schema
CheckoutHosted cart, card fields, walletssrc/lib/actions/checkout.ts (71 lines) — we still hand this off
Seller of record / taxThey register and remit; you never see a VAT returnNot taken back. A merchant of record still does it
EntitlementImplicit: "bought product X, gets file X"src/lib/entitlements.ts (75) + src/lib/actions/redemption.ts (142)
File deliveryA download link in their receiptsrc/lib/download.ts (373) + src/app/api/download/route.ts (157)
Receipt, refundsTheir email, their refund buttonsrc/lib/email/purchaseWelcome.ts (72), src/lib/refundEligibility.ts (62), src/lib/actions/refund.ts (112)

Add the webhook that connects them — src/lib/lemonsqueezy/{signature,variants,webhook,webhookDb}.ts (25 + 119 + 287 + 43) and its route handler (69) — and the commerce layer of this storefront is 1,607 lines across 13 files. wc -l on that file list is the whole methodology; there is no benchmark to trust here, just a count you can reproduce against your own repo.

Two observations about how that total distributes. The checkout is 71 lines, because we did not take it back. Entitlement and delivery are 1,148 lines between them, because we did.

The half worth keeping hosted: checkout and tax

The single most expensive thing a hosted storefront gives you is not the checkout UI. It is being the seller of record — the entity that registers for VAT in the EU, for GST in Australia, for sales tax in the US states with digital-goods nexus, and files those returns. Nobody replaces that with 400 lines of TypeScript.

So we didn't. Checkout is a hosted URL built by a Server Action:

export async function getCheckoutUrl(tier: Tier): Promise<CheckoutUrlResult> {
  // The UUID builds the checkout URL; the numeric id is what the webhook
  // will match on later. Refuse to sell unless BOTH are configured — a
  // checkout opened without the numeric id counterpart takes the buyer's
  // money and then falls through `tierForVariantId` to "ignored".
  const variantUuid = variantUuidForTier(tier);
  if (!variantUuid || !variantIdForTier(tier)) {
    return { ok: false, reason: "not_configured" };
  }
  // ...buyer id and email come from the verified server session, never
  // from the request body.
}

That guard is not defensive padding. Our checkout URL is keyed by one identifier and our webhook matches on a different one, so a half-configured product is a live "Buy" button that charges the card and grants nothing. Refusing to render the button is the only safe failure. If you take checkout in-house, this class of bug is now yours: every payment provider has some identifier that must agree on both sides of the round trip.

The fee comparison between merchants of record — and the one silent failure mode that charges a buyer and grants nothing — is worked through with real numbers in LemonSqueezy vs Stripe for digital products, so it is not repeated here.

The half you cannot keep hosted: entitlement

A hosted storefront models ownership as product bought → file delivered. That is exactly right until your catalogue has bundles, tiers, or multi-format products, at which point you are maintaining one "product" per combination and a spreadsheet of who is owed what.

Ours is a pure function over slots:

export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
  if (slot.status !== "active") return false;
  switch (slot.kind) {
    case "all_access":
      return true;
    case "all_templates":
      // Every premium template, all framework editions — but never the
      // backend zip (that's all_access / T3 only).
      return target.framework !== "backend";
    case "template_single":
      // One template, all its framework editions (framework is not a lever;
      // owning the product covers every edition).
      return (
        slot.productSlug === target.productSlug &&
        target.framework !== "backend"
      );
  }
}

export function authorizeDownload(slots: Slot[], target: DownloadTarget) {
  return slots.some((s) => slotCovers(s, target));
}

Seventy-five lines, no I/O, no framework imports — which is why it is the most heavily unit-tested file in the repository. The rule "one purchase covers every framework edition of that template" is a sentence in our licence; on a hosted platform it is either five separate products or a support inbox.

This is the real dividing line in the buy-vs-build question. If your ownership rule fits in the sentence "they bought this file, they get this file", a hosted storefront models your business correctly and you should keep it. If it doesn't, you will be fighting the platform's data model forever, and 75 lines is cheap.

Delivery is where the sharp edges are

The download endpoint is the piece people underestimate most. Ours runs its checks in a fixed order, and the order is the design:

  1. Authenticate the session; downloads are refused until the email is verified.
  2. Validate the requested product and framework against the catalogue.
  3. Refuse unreleased editions before any entitlement work — an unknown edition and an unowned one must be indistinguishable.
  4. Authorize with authorizeDownload — the single deny-by-default gate.
  5. Rate-limit and audit in one atomic step.
  6. Sign a short-lived storage URL and redirect.

Step 5 is the one that bites. The obvious implementation — count today's downloads, then insert a row — is a check-then-act race: two concurrent requests both read "4 of 5 used" and both proceed. Ours does the count and the insert inside one serialized database function, and an infrastructure error there fails closed:

// Atomic rate-limit + audit write in one serialized step (R-13; closes the
// F-1 check-then-act TOCTOU race). Over-limit records nothing and returns
// withinLimit:false → 429; an infrastructure error throws → fail-closed 500
// (no signed URL issued when the limit/audit write couldn't run).

The delivery record is also what makes refunds enforceable. Our policy is 14 days, no downloads, and the eligibility engine is coverage-based: a successful download of anything this order's slots cover blocks the refund, using the same slotCovers the download endpoint uses. One function, two callers, so the disabled button and the server-side enforcement cannot disagree. That trade — and the attribution "fix" that quietly opens a download-then-refund bypass — is in a digital product refund policy you can enforce in code.

The webhook is the whole contract

Everything above is downstream of one HTTP request from the payment provider. Get its verification wrong and your entitlement table is writable by anyone who can guess the URL.

export function verifySignature(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): boolean {
  if (!signatureHeader) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signatureHeader, "hex");
  return a.length === b.length && b.length > 0 && timingSafeEqual(a, b);
}

Twenty-five lines, and three of them are load-bearing:

  • Raw body, always. The route reads await req.text() and never req.json() first, because re-serializing a parsed object can differ byte-for-byte from what was signed.
  • timingSafeEqual throws on a length mismatch. A malformed header must return false, not a 500, so the length check comes first.
  • Decode to bytes before comparing. Comparing hex strings as UTF-8 buffers works, but the byte comparison is the thing the primitive is for.

The defect we shipped and fixed in this file is worth more than the code: the purchase-confirmation email was sent fire-and-forget, unawaited, after the response. That works perfectly in development and drops mail silently in production, because a serverless invocation has no guaranteed lifetime once the response is sent. The fix is after() from next/server, which keeps the invocation alive for scheduled work without blocking the response. The full pattern — two emails, opposite failure policies — is in transactional email that can't break the purchase it confirms.

What the hosted platform is really selling you

Not the checkout. The failure paths.

Our webhook has explicit handlers for an order that arrives with no account attached to it, and for a refund event referencing an order we have no row for — the refund webhook racing ahead of the create, or a lost delivery. Both raise alerts and wait for a human. On a hosted storefront, that human works for the platform.

So the honest recommendation:

  • Stay hosted if you sell fewer than a handful of products, your ownership rule is one-file-per-purchase, you want discovery from the platform's own audience, or nobody on the team wants to be paged about a webhook at 2 a.m.
  • Own the storefront if your product pages need to rank on your domain, your entitlement rule has tiers or bundles, you want the checkout inside your own funnel, or the percentage on a growing revenue line has become the largest line item you cannot negotiate.
  • Either way, keep a merchant of record unless you have a reason to want the tax registrations.

Mistakes and how they show up

MistakeHow it shows upFix
Parsing the webhook body before verifying itSignature fails intermittently on payloads with unicode or key reorderingRead the raw text, verify, then parse
Comparing signatures with ===Works, but leaks timingtimingSafeEqual on decoded bytes, after a length check
Count-then-insert rate limitingLimit silently exceeded under concurrency; no error anywhereOne atomic, serialized count+insert
Fire-and-forget confirmation emailPerfect in dev, silently dropped in productionafter() (or your platform's waitUntil)
Modelling entitlement as product-to-fileA new bundle means duplicating every productA slot model with a covers predicate
Half-configured product idsBuy button charges the card, grants nothingRefuse to render the button unless every id resolves
Public storage bucket "just for now"Every paid file is a URL away from being freePrivate bucket, short-lived signed URLs per request

Frequently asked questions

Is leaving a hosted storefront cheaper? Only past a revenue line you can compute yourself: percentage-of-sales against hosting plus the engineering hours above, amortised. What actually pushes people off is usually not the fee — it is an ownership rule the platform cannot model, or wanting the product pages on their own domain.

Can I keep the checkout and replace only the delivery? Yes, and it is the split we run. A merchant of record's hosted checkout plus your own product pages, entitlements and downloads is the cheapest configuration that still gives you the storefront. The webhook is the seam.

How long did the commerce layer take? The line counts are in the table; the honest answer is that entitlement and delivery took several times longer than their size suggests, because almost all of the work is failure paths — races, replays, unverified emails, unreleased editions — and none of it is visible in a happy-path demo.

Do I need a template to start? No, but the product pages are the part with the most surface area and the least novelty. Starting from a landing or shop template and writing only the commerce layer is a reasonable way to skip the half of the work that is design.

Templates in this post

ASoc Nexus is a SaaS app landing page — an operations dashboard mock, a solutions grid, integrations and role-based permissions blocks, and an app-download section with a QR code. ASoc Nimbus is a cloud-workspace marketing site with a five-capability grid, a three-step onboarding and a Starter/Growth/Enterprise pricing block, which is the shape most digital-product businesses need on day one. ASoc Nova markets a crypto trading and custody platform, with live-style price widgets, a services grid and an FAQ built around security and fees.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison10 min read

MDX vs a Headless CMS: Choosing by Who Writes the Posts

An editorial-workflow decision wearing an architecture decision's clothes. What MDX buys you in CI, the Turbopack plugin trap, and the four cases where a CMS simply wins.

Read more