Shopify Advantages, Priced in the Code You Write Without Them
Checkout, order records, apps, the admin: each Shopify advantage measured against the 1,545 lines of TypeScript and 271 of SQL it took to replicate a subset.
Shopify's advantages are checkout, payments, tax, fraud handling, hosting, an app ecosystem and an admin someone else maintains. The honest way to measure them is not to list them — it is to count what you write when you do not have them. For this storefront that came to 1,545 lines of TypeScript across 12 files and 271 lines of SQL across 8 migrations, to replicate a subset.
We sell Next.js templates, so the bias is declared up front: we are the people who did not use Shopify. That makes this a useful place to read about its advantages, because every one of them below has a price tag we actually paid. If you want the decision rather than the inventory, that is Shopify vs a Next.js storefront. This post is the inventory.
The advantages, priced
| Shopify advantage | What it saves you | What we wrote instead |
|---|---|---|
| Hosted checkout | PCI scope, card forms, wallets, conversion tuning | src/lib/actions/checkout.ts (71 lines) + a payment provider |
| Order ingestion | Nothing to build | src/lib/lemonsqueezy/webhook.ts (287 lines) |
| Signature / trust boundary | Nothing to build | src/lib/lemonsqueezy/signature.ts (25 lines) |
| Access control on digital goods | App-provided | src/lib/entitlements.ts + src/lib/download.ts (448 lines) |
| Refunds | One button in the admin | refund_order RPC + src/lib/actions/refund.ts (112 lines) |
| Transactional email | Built in | src/lib/email/purchaseWelcome.ts (72 lines) |
| Abuse limits on downloads | App-provided | An RPC with an advisory lock (67 lines of SQL) |
| The admin itself | Everything | src/app/dashboard/page.tsx (505 lines) |
Every row is a thing we now own, including its bugs. Three of those bugs are below, and all three are the kind a hosted platform would simply never have handed us.
Advantage 1: the checkout is not the part you think
People describe Shopify's checkout as "a form". The form is the cheap part. What you are buying is a payment flow that has been conversion-tested across a very large number of carts, is PCI-compliant without you entering scope, supports the wallets buyers expect, and — the part that bites — hands you a trustworthy record of what happened.
Our equivalent of that last piece is a webhook, and the first thing it must do is prove the message is real:
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);
}
Three non-obvious details sit in those six lines. The HMAC is over the raw body, which is why the route handler calls req.text() and never req.json() first — re-serializing a parsed object can differ byte-for-byte from what was signed, and then every signature fails for reasons that look like a secret mismatch. Both sides are decoded to bytes before comparison, because timingSafeEqual throws on a length mismatch and a malformed header has to resolve to false, not throw. And the comparison is constant-time at all, which a === would not be.
None of that is hard. All of it is load-bearing, and all of it is yours to get right. The full walkthrough is in LemonSqueezy checkout in Next.js.
Advantage 2: Shopify's order table is already idempotent
Any payment provider will deliver a webhook more than once. Shopify's advantage here is that duplicate delivery is somebody else's invariant — the order either exists or it does not, and you never think about it.
Ours is 37 lines of SQL. create_order_with_slots inserts the order and its entitlement slots in one transaction, and the database decides whether the delivery was new:
insert into public.orders (ls_order_id, ..., status, user_id, raw)
values (p_ls_order_id, ..., 'paid', p_user_id, p_raw)
on conflict (ls_order_id) do nothing returning id into v_order_id;
if v_order_id is null then
select id into v_order_id from public.orders where ls_order_id = p_ls_order_id;
return query select v_order_id, false; return;
end if;
The RPC is the sole arbiter of "was this new?". created: false means a duplicate, so no slots are re-created and no welcome email goes out twice. Doing it in application code instead — check, then insert — is the race this shape exists to avoid. Same reason the refund path is one transaction: a crash between "revoke the slots" and "mark the order refunded" would leave a refunded buyer with live access.
Bug we shipped and fixed: the hourly download limit was written as check-then-act — read the count, then insert the audit row separately. Parallel requests all read the same count, all passed the < limit check, and all inserted, so a burst walked past the cap. The fix moved both into one SECURITY DEFINER RPC serialized by a per-user advisory lock. On Shopify this is an app's problem, and the app's author already hit it.
Advantage 3: the app ecosystem is a maintenance transfer
8,000-plus apps is the advantage most often quoted and most often misread. The value is not the feature list — most of those features are a week's work. The value is that the app's author carries the maintenance, the provider's API changes, and the edge cases, forever.
Digital-goods delivery is the example we know best. The feature sounds like "give the buyer a download link". What it actually needs:
- Ownership derived from server-held state, never from a request parameter.
- Short-lived signed URLs. Ours expire in 60 seconds (
SIGNED_URL_TTL_SECONDS), so a link pasted into a forum is dead on arrival. - A per-user rate limit that scales with what the buyer bought — a flat cap of 30/hour is fine for a single-template buyer and broken for All-Access, whose legitimate first-day behaviour is pulling all 116 ready editions.
- An audit trail that survives account deletion. Migration
0005_retain_anonymized_download_audit.sqlswitched the foreign key from cascade-delete toon delete set nullfor exactly that reason: a record a self-serve delete button can erase is not a record.
That is one app. The mechanics are in gated file downloads in Next.js.
Advantage 4: someone else's platform knows about serverless
This one is subtle and cost us a real defect. Our webhook sends a welcome email after the order commits. The obvious code is fire-and-forget:
sendPurchaseWelcome(email, tier); // do not do this
On Vercel, an un-awaited promise has no guaranteed lifetime once the response is sent. The invocation can freeze or terminate before the Resend call completes, and the email silently vanishes — intermittently, which is the worst failure shape there is. The fix is after() from next/server, which is backed by waitUntil() and keeps the invocation alive for scheduled work without blocking the response:
sendPurchaseWelcome: (email, tier) =>
after(() => sendPurchaseWelcome(email, tier)),
Awaiting it instead would have been worse: a slow mail provider would then delay the webhook's response and invite the provider to retry a delivery that already succeeded. A hosted platform has made this decision for you and tested it against its own runtime. That is an advantage, and it does not appear on any feature page.
Advantage 5: the admin you do not have to design
Shopify's admin covers orders, refunds, customers, inventory and reporting. Ours covers three tabs and 505 lines, and still needed a defect fixed: downloads were computed per order, so a buyer who owned one template and later bought All-Access saw it twice with two identical buttons. Aggregating across all of a user's orders fixed it — in a shared function, so the dashboard and /api/download can never disagree about ownership.
We also carry two alerting paths Shopify would not have required. An order_created with no user_id means someone bought outside our checkout, so the purchase needs attaching by hand. An order_refunded for an unknown order means the refund webhook raced ahead of the create, or a delivery was lost. Both land as structured console.error calls awaiting a log drain. Neither is clever; both are obligations that arrive with the territory. Related reading: order confirmation pages in Next.js.
Where the advantages stop paying
Being fair to the other side, three things genuinely got better by owning the code:
- Performance has no app tax. Every page on this site is prerendered static HTML except eight routes; desktop Lighthouse performance is 100 across all eight main pages, accessibility and SEO 100 on desktop and mobile both. On a themed hosted store your ceiling is set by the scripts the apps inject.
- The pricing model is ours. Three tiers with entitlement semantics (one template / all templates / all access plus the backend) are 75 lines of pure function. Expressing that in someone else's product model is where "just use the platform" projects go to die.
- A fixed cost instead of a percentage. Hosting plus a provider's fee, with no per-transaction platform cut on top.
Note what is not on that list: time to launch, and total bugs. Shopify wins both, decisively.
Troubleshooting the DIY path
| Symptom | Likely cause | Fix |
|---|---|---|
| Every webhook signature fails | Body parsed before the HMAC was computed | Read req.text() first; sign the raw bytes |
timingSafeEqual throws on some requests | Buffers of different lengths | Compare lengths first, return false |
| Buyers occasionally get no welcome email | Un-awaited promise killed when the response is sent | Schedule it with after() / waitUntil() |
| Duplicate entitlements after a retry | Idempotency enforced in app code | on conflict (…) do nothing and let the DB decide |
| A quota is exceeded under parallel requests | Check-then-act across two statements | One transaction, one advisory lock per user |
| A refunded buyer keeps access | Revoke and mark-refunded in separate statements | Do both in one transactional RPC |
FAQ
What are the main advantages of Shopify? A hosted, PCI-compliant checkout; payments, tax and fraud handled; hosting and uptime; a large app ecosystem that transfers maintenance to someone else; and an admin for orders, refunds and customers that you never design.
Is Shopify's biggest advantage really the checkout? The checkout is the visible part. The bigger advantage is that order records, duplicate deliveries, refunds and the platform's own runtime behaviour are all invariants someone else maintains — the things that cost us three distinct bugs to get right here.
When do Shopify's advantages stop being worth it? When the store is one surface of a product you already build, when the frontend's performance or design is the differentiator, or when your pricing model does not fit the platform's object model. Our 14-day refund window and tiered entitlement slots are an example of the last.
Can you keep Shopify's advantages and still own the frontend? Yes — the headless path keeps its checkout and payments while you render the storefront. You then own the frontend performance story without inheriting the four jobs above, which is the compromise most of this debate skips.
If the frontend is the part you want to own
Owning the storefront is only worth it if the storefront is good, and starting from a blank Next.js app is not the fast route there. The Next.js landing page templates and Tailwind landing page templates ship with the static-by-default structure, metadata and accessibility work already done.
