I woke up on February 7th and decided LemonSqueezy was dead.
Not because it was bad software... it wasn't. It worked fine. But their "integration" was a redirect. Click buy, leave my site, land on their domain, stare at their design. Or worse -- an iframe. A little LemonSqueezy window bolted onto my page like a billboard on a cathedral.
I built a neobrutalist design system. Hard edges. Obsidian void. JetBrains Mono. Every pixel deliberate. And the single most important page on the platform -- the one where money actually changes hands -- looked like someone else's SaaS template.
Nah. That's not gonna work.
I don't know... maybe most people wouldn't care. Maybe you're supposed to just accept that your checkout page looks like everyone else's checkout page. But I'm not normal about this stuff. I spent months making every other pixel mine and then the payment page is just... someone else's brand? No.
So I speedran it. 21 commits. 48 files. One day.
By midnight, grep -ri lemonsqueezy src/ returned nothing. Zero references. Complete purge.
Stripe Elements, Not an Iframe
Stripe Elements. Not EmbeddedCheckout -- that's just another iframe with a nicer name. <PaymentElement />. The only thing Stripe controls is the card input itself, because PCI compliance demands it. Everything else is mine. My layout. My copy. My brutalist cards with hard shadows. My domain colors.
The architecture wasn't complicated:
stripe.server.ts-- client factory usingcreateFetchHttpClient()for Cloudflare Workers (no Node.js, nohttpmodule)create-subscription.ts-- creates an incomplete subscription, returns the PaymentIntent client secretbilling-portal.ts-- Stripe Customer Portal redirectwebhooks.stripe.ts-- webhook handler withconstructEventAsync()and WebCrypto signature verification
WebCrypto. Because I run on Cloudflare Workers and crypto.createHmac doesn't exist at the edge. Stripe's SDK supports createSubtleCryptoProvider() for exactly this case. Most devs don't know that because most devs aren't deploying to edge runtimes.
I mean... I say "most devs" like I'm some authority. I'm one dude shipping a personal finance platform from his home office. But yeah, the point stands -- edge runtime constraints force you to actually learn what's under the hood.
The Ghost Subscriptions
Halfway through testing, I found something ugly. The checkout page was using a useEffect on mount to call create-subscription. Every time someone visited the checkout page -- even just to look -- it spun up an incomplete Stripe subscription. Browse the page three times, get three dangling subscriptions in your Stripe dashboard.
This is the kind of bug that doesn't show up in unit tests. It shows up at 2am when you see 14 incomplete subscriptions from your own test account and you're like... what the hell is happening.
Fix: two-step flow. Page loads with a pricing summary. No API call. User clicks CONTINUE_TO_PAYMENT. Only then does the subscription get created and <PaymentElement /> appears. Explicit user action. No ghosts.
Clover Says Hello
Then at 9pm -- after the migration was "done," after the tests were "passing" -- Stripe API 2026-01-28 "Clover" says hello. invoice.payment_intent, the field I was using to get the client secret? Deprecated. Gone. New path: invoice.confirmation_secret.
Also killed: automatic_tax. Requires customer address collection I hadn't built yet. Ripped it out. Ship what works, add tax later.
Three commits in two hours to unfuck what a breaking API change broke. 76e64b5, ec29c12, d01beab. Check the hashes if you want. The timestamps tell the story.
The Purge
Either way... here's the final scoreboard.
Deleted lemonsqueezy.server.ts. Deleted lemonsqueezy.server.test.ts. Deleted the old webhook route. Rotated production secrets -- 4 Stripe keys added, 2 LemonSqueezy keys burned. Updated privacy.tsx and terms.tsx. Rewrote billing.tsx from scratch. Stripped the <script> tag from root.tsx. Rewired teaser-modal.tsx to point at /billing/checkout.
The old webhook handler matched email addresses to find users -- LemonSqueezy's approach. Fragile as shit. Change your email, lose your subscription. The new handler uses stripe_customer_id on the profiles table with a unique partial index. Deterministic. Unfuckable.
48 files touched. 21 commits. 617 tests passing at end of day.
Why It Only Took One Day
But yeah... the real thing I keep coming back to isn't the migration itself. It's why I could do it in one day.
React Router loaders and actions. Server-side data fetching with clear boundaries. When the payment provider changes, the loader changes. The components don't care where the data came from. Cloudflare Workers edge runtime forcing me to think about what I actually need. Zod schemas validating at every boundary so I can swap the source and keep the shape. The 800-line file size rule meant every piece was small enough to reason about independently.
I didn't plan for this day. But the architecture I'd been grinding on for months meant that when I decided a payment provider had to die... it could die in one day. Same thing happened when I migrated from one JSONB column to nine normalized tables -- the architecture absorbed the blow.
LemonSqueezy didn't die because it was bad software. It died because it stood between me and design ownership on the most important page of my platform.
617 tests. Zero references. One day.