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.
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.
| Level | You own | Provider owns | Cost to you | Use when |
|---|---|---|---|---|
| Hosted link | The link and its query data | The whole page | Lowest | Fixed-price products, small team |
| Overlay | The link, the button, the page around it | The payment form, shown in a modal | Low | You want the buyer to stay on your page |
| Embedded form | Layout around the form | Card fields inside your page | Medium | You need conversion-level control of the surrounding page |
| Fully custom | Everything including field UI | Tokenisation and the payment API | High, plus compliance scope | Marketplaces, 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.
| Customisation | Where it lives | Why it matters |
|---|---|---|
| Prefilled email | checkout[email] in the URL | The buyer does not retype the address their account already has |
| Buyer identity | checkout[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 mode | embed=1 | Renders the page for the modal instead of a full page |
| Which tier | The variant in the URL path | Decides 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:
| State | When | What the buyer sees |
|---|---|---|
| Loading | Session and ownership still resolving | A disabled spinner, so the label never flashes "Sign in to buy" and then changes |
| Signed out | No session cookie | A link to /login?next=/pricing?buy=<tier> |
| Owned | An order already covers this tier or a higher one | "Owned, view in dashboard", so nobody pays twice |
| Unavailable | The tier is not configured, or URL building threw | "Checkout opening soon" |
| Ready | A checkout URL resolved | A .lemonsqueezy-button anchor |
| Auto-open | Returning 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.
Make the overlay degrade to a link
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
| Symptom | Likely cause | Fix |
|---|---|---|
| Checkout URL returns 404 | Numeric variant id used on the checkout path | Use the variant UUID in the URL |
| Buyer charged, access never granted | UUID configured where the numeric id belongs | Configure both; gate the button on both |
Email arrives with a space in place of + | Query string built by concatenation | Use new URL() and searchParams.set |
| Click opens the full page instead of the overlay | Script scanned the page before the anchor rendered | Call the re-scan function after the anchor mounts |
| Overlay blocked in production only | CSP missing the script host or frame-src | Add the checkout origin to script-src and frame-src |
| "Sign in to buy" flashes then changes | No loading state while the session resolves | Render a neutral disabled state first |
| Buyer pays twice for the same tier | Ownership not checked before showing Buy | Check 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.
