Skip to main content
ASoc
Tutorial

Custom Checkout: What to Own, What to Rent, and the Bug That Charged Without Granting

A custom checkout is mostly data and button states, not a payment form. The 3 URL fields we set, 5 button states, and the variant-id mix-up that charges buyers and grants nothing.

The ASoc Team9 min read

A custom checkout is rarely a checkout you build from scratch. In practice it means choosing how much of the payment page you own, then carrying your own data through it: who the buyer is, what they bought, and whether they should be allowed to buy at all. The card form stays with the payment provider. The parts you customise are the entry point, the data attached to the order, and every state of the button that leads there.

This is how the ASoc marketplace does it, with the real files. It sells 111 templates through a hosted LemonSqueezy checkout, and the customisation lives in under 400 lines across three files rather than in a checkout page we maintain.

Four levels of "custom"

Most guides to customising a checkout are platform manuals (change the colours, add a field). The decision that matters comes first: how much of the page is yours.

LevelYou ownProvider ownsCost to youUse when
Hosted linkThe link and its query dataThe whole pageLowestFixed-price products, small team
OverlayThe link, the button, the page around itThe payment form, shown in a modalLowYou want the buyer to stay on your page
Embedded formLayout around the formCard fields inside your pageMediumYou need conversion-level control of the surrounding page
Fully customEverything including field UITokenisation and the payment APIHigh, plus compliance scopeMarketplaces, unusual flows

We use the first two together: a hosted checkout URL that, when the provider's script is present, opens as an overlay, and when it is not, simply navigates. That fallback is the part that makes the setup robust, and it is the first thing worth copying.

What we actually customise

Almost none of it is visual. The customisation that earns its keep is data.

CustomisationWhere it livesWhy it matters
Prefilled emailcheckout[email] in the URLThe buyer does not retype the address their account already has
Buyer identitycheckout[custom][user_id]Comes back on the order webhook as meta.custom_data.user_id, so a payment attaches to an account without matching on email
Overlay modeembed=1Renders the page for the modal instead of a full page
Which tierThe variant in the URL pathDecides what the buyer is granted

The function that builds it is buildCheckoutUrl in src/lib/lemonsqueezy/variants.ts:

export function buildCheckoutUrl(
  variantUuid: string,
  userId: string,
  email: string,
): string {
  const store = process.env.LEMONSQUEEZY_STORE_SUBDOMAIN;
  if (!store) {
    throw new Error("LEMONSQUEEZY_STORE_SUBDOMAIN not configured");
  }
  const url = new URL(
    `https://${store}.lemonsqueezy.com/checkout/buy/${variantUuid}`,
  );
  url.searchParams.set("checkout[email]", email);
  url.searchParams.set("checkout[custom][user_id]", userId);
  url.searchParams.set("embed", "1");
  return url.toString();
}

Building the URL with URL and searchParams.set rather than string concatenation means the email address is percent-encoded for you. An address containing + is the classic way a hand-built query string turns jane+work@example.com into jane work@example.com.

Resolve the URL on the server, from the session

The browser never builds this URL. getCheckoutUrl in src/lib/actions/checkout.ts is a Server Action that takes one argument from the client, the tier, and derives everything else from the verified session:

const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
const userId = claims?.sub;
const email = typeof claims?.email === "string" ? claims.email : undefined;
if (!userId || !email) {
  return { ok: false, reason: "unauthenticated" };
}

If the client could pass the user id, anyone could attach a purchase to someone else's account by editing a request. Taking only the tier from the caller removes the question. The variant ids and the store subdomain are server-only environment variables for the same reason: they are never inlined into the client bundle.

The defect that charged without granting

This is the one worth reading twice if you wire up a hosted checkout. A LemonSqueezy variant has two identifiers that are not interchangeable:

  • the numeric id, which webhook payloads carry at data.attributes.first_order_item.variant_id
  • the UUID, which the hosted /checkout/buy/<id> path accepts

We confirmed on 2026-08-03 that the numeric id returns a 404 on the checkout path. The dangerous direction is the reverse. If the UUID is configured where the numeric id belongs, checkout works perfectly, the buyer pays, and then every webhook falls through the variant lookup to a 202 ignored. Charged, granted nothing, no error anywhere.

The fix is to configure them separately and to refuse to sell a tier unless both exist:

export function checkoutConfiguredForTier(tier: Tier): boolean {
  return (
    variantUuidForTier(tier) !== null &&
    variantIdForTier(tier) !== null &&
    Boolean(process.env.LEMONSQUEEZY_STORE_SUBDOMAIN)
  );
}

A custom checkout should fail closed. Showing a disabled button costs a sale you could not have fulfilled. Showing a working one costs a refund and a support ticket.

The button is a state machine

Customising the checkout means designing every state the button can be in. src/components/molecules/BuyButton.tsx renders five, plus an auto-open behaviour, and each exists because the case it covers goes wrong without it:

StateWhenWhat the buyer sees
LoadingSession and ownership still resolvingA disabled spinner, so the label never flashes "Sign in to buy" and then changes
Signed outNo session cookieA link to /login?next=/pricing?buy=<tier>
OwnedAn order already covers this tier or a higher one"Owned, view in dashboard", so nobody pays twice
UnavailableThe tier is not configured, or URL building threw"Checkout opening soon"
ReadyA checkout URL resolvedA .lemonsqueezy-button anchor
Auto-openReturning from login with ?buy=<tier>The overlay opens without a second click

The signed-out branch skips the network entirely. hasAuthCookie() answers "is there a session at all" from the cookie, so a logged-out visitor, which is most of them, never loads the auth library or makes a round trip just to be told to sign in.

The ready state is an ordinary anchor whose href is the hosted checkout. The overlay is an enhancement layered on top:

<Button
  href={checkoutUrl}
  className={`lemonsqueezy-button ${className}`.trim()}
  onClick={(e) => {
    track("buy_clicked", { tier });
    if (window.LemonSqueezy?.Url?.Open) {
      e.preventDefault();
      window.LemonSqueezy.Url.Open(checkoutUrl);
    }
  }}
>
  Buy now
</Button>

If the provider's script loads, the click opens the modal. If a content blocker removed it, or it has not finished loading, preventDefault never runs and the browser follows the link to the same checkout as a full page. The buyer can always pay; only the polish varies.

One timing detail: React can render the anchor before the provider's script has scanned the page for buttons. BuyButton calls window.createLemonSqueezy?.() in an effect once the URL exists, which re-scans. Without it, the click can fall through to the full-page checkout even though the script is on the page.

Content Security Policy is part of the checkout

A custom checkout that works in development and breaks under your production headers is a common launch-day failure. Our CSP in next.config.ts opens three directives for the checkout: the provider's script hosts in script-src, its wildcard origin in frame-src (the overlay is an iframe), and the same wildcard in form-action. Nothing broader. If you tighten headers after the checkout works, retest the overlay, not just the page.

Troubleshooting

SymptomLikely causeFix
Checkout URL returns 404Numeric variant id used on the checkout pathUse the variant UUID in the URL
Buyer charged, access never grantedUUID configured where the numeric id belongsConfigure both; gate the button on both
Email arrives with a space in place of +Query string built by concatenationUse new URL() and searchParams.set
Click opens the full page instead of the overlayScript scanned the page before the anchor renderedCall the re-scan function after the anchor mounts
Overlay blocked in production onlyCSP missing the script host or frame-srcAdd the checkout origin to script-src and frame-src
"Sign in to buy" flashes then changesNo loading state while the session resolvesRender a neutral disabled state first
Buyer pays twice for the same tierOwnership not checked before showing BuyCheck owned tiers, including higher ones

Frequently asked questions

Can I customise the checkout page itself? How much you can change depends on the provider and your plan, so read that provider's current documentation rather than assuming. The part that is always yours is the data you pass in and the page around the button.

Is a custom checkout PCI-compliant? A hosted link or an overlay keeps card data on the provider's domain, which keeps your compliance scope small. Collecting card numbers in your own fields moves that scope to you. See LemonSqueezy vs Stripe for digital products for how the providers differ on tax and merchant-of-record duties.

How do I know which order belongs to which user? Attach your own user id to the checkout as custom data, and read it back on the signed order webhook. Never trust the redirect back to your site as proof of payment (what the webhook must then survive is the subject of online store checkout); the LemonSqueezy checkout walkthrough covers why only the webhook should grant access.

Should the checkout require an account? It depends on what you deliver. We require one because the product is a gated download that must be re-downloadable later. For a one-off file, guest checkout removes a step, and gated file downloads in Next.js shows the alternative ownership model.

Start from a finished front end

The checkout is the part to customise once. The page that sends buyers to it is a layout decision you can inherit. ASoc Catalyst is an AI-automation agency landing page with a three-tier pricing section, ASoc Chain a DeFi protocol landing page, and ASoc Cognition an AI-consulting agency landing page, each a typed, statically rendered Next.js project.

Browse the full set of Next.js landing page templates, or the Tailwind landing page templates if you want to bring your own framework.

Keep reading

Tutorial8 min read

What's Actually in a Data Dashboard? 59 Views, Counted

9 admin templates, 59 dashboard views, audited from the catalog — what a coded data dashboard actually contains versus a BI tool.

Read more
Tutorial12 min read

DB Schema: Two Meanings, Six Tables, and 271 Lines of Postgres

In Postgres “schema” means both the structure and a namespace. Here is a real commerce schema — 6 tables, 8 migrations, and the four times it had to change.

Read more