Next.js License Key: Why This Storefront Doesn't Have One
No key field anywhere in this codebase's entitlement types — ownership is a database row scoped to a session, checked fresh on every download.
A license key is a string: generated at purchase, stored by the buyer, entered somewhere to unlock access. This storefront doesn't have one anywhere in its type system. Ownership of a template is a database row scoped to a user_id, checked by re-verifying the buyer's signed-in session on every download — there's no secret to type in, because there's no secret at all. Grep every interface in the download and entitlement code and the word "key" doesn't appear once.
The model most tutorials assume
The common Next.js pattern for selling downloadable software is: a purchase generates a license key, the buyer receives it by email, and either a desktop app phones home to validate it or a download page asks the buyer to paste it back in before releasing the file. It works, and it has one real advantage this codebase's approach doesn't: a key travels independently of an account. No sign-in, no session — just the string.
It also has the costs that come with any bearer secret. A key that unlocks a download is a key that can be shared, resold, or leaked in a support ticket screenshot, and none of those are visible to the seller unless something else (an activation-count limit, a device fingerprint) is layered on top. Revoking access means invalidating the specific key, which means storing a mapping from key to purchase in the first place — you've built an authentication system, just one where the credential is a string instead of a password.
What this codebase does instead
Every type involved in deciding whether a download is allowed:
// src/lib/entitlements.ts
export type SlotKind = "template_single" | "all_templates" | "all_access";
export interface Slot {
kind: SlotKind;
productSlug: string | null;
framework: string | null;
status: "active" | "revoked";
}
export interface DownloadTarget {
productSlug: string;
framework: string;
}
export function authorizeDownload(slots: Slot[], target: DownloadTarget): boolean {
return slots.some((s) => slotCovers(s, target));
}
// src/app/api/download/route.ts — DownloadParams and DownloadSession
export interface DownloadParams {
productSlug: string | null;
framework: string | null;
ip: string | null;
userAgent: string | null;
}
export interface DownloadSession {
userId: string;
emailVerified: boolean;
}
Nothing in DownloadParams, DownloadSession, or Slot accepts anything resembling a key. authorizeDownload takes a list of the caller's own slots and a target, and returns a boolean — the caller's identity comes entirely from getSession() resolving a verified userId, never from anything the client supplies as a parameter. The full authorization ordering — authenticate, verify email, validate input, authorize, rate-limit, sign — runs on that userId, and there's no step in it that could accept a forged credential the way a leaked key could.
Redemption is the closest thing to a code, and it still isn't one
The one flow that sounds like it might involve a key is redemption — a buyer who purchased a multi-seat Single-template slot picks which catalog product it applies to. It's still entirely account-scoped:
// src/lib/actions/redemption.ts
const { data: updated, error: updateError } = await admin
.from("entitlement_slots")
.update({
product_slug: product.slug,
framework: null,
redeemed_at: new Date().toISOString(),
})
.eq("id", slotId)
.eq("user_id", user.id)
.eq("status", "active")
.is("redeemed_at", null)
.select("id");
if (!updated || updated.length === 0) {
return { ok: false, message: ALREADY_REDEEMED };
}
slotId here identifies a row the buyer already owns — it's never emailed, never displayed as something to copy elsewhere, and the .eq("user_id", user.id) clause means presenting someone else's slotId fails silently rather than redeeming their slot. The four-clause WHERE is doing real work beyond authorization: it's an atomic conditional update, so if the buyer double-clicks "Redeem" or has two tabs open, exactly one request can match status = "active" and redeemed_at IS NULL — the second arrives after the first has already flipped redeemed_at, matches nothing, and returns the same "already redeemed" message a genuine double-redemption attempt would get. The one-time-payment entitlement engine this plugs into has exactly two states — active and revoked — and redemption is a one-way transition inside that, not a separate credential layer.
No key at checkout time either
The same rule holds one step earlier, before there's anything to redeem. getCheckoutUrl is what builds the LemonSqueezy hosted checkout link a buyer is sent to:
// src/lib/actions/checkout.ts
export async function getCheckoutUrl(tier: Tier): Promise<CheckoutUrlResult> {
// tier is the only client input — the buyer's id and email are derived
// from the verified server session (R-5/R-11: ownership from session,
// never request params), so a caller cannot attach a checkout to another
// user's account.
...
}
tier — which pricing tier to buy — is the only thing the client is trusted to say. The buyer's identity comes from the session on the server, the same way it does at download time and at redemption time. There's no point in this flow, from checkout through to the eventual file byte-stream, where a client-supplied identifier decides whose account a purchase or a download belongs to. A license key breaks that pattern by design — it's meant to be portable, checkable without a session — which is exactly the property this codebase never needed and didn't build.
The trade, side by side
| License key | This codebase's account-scoped slot | |
|---|---|---|
| What the buyer holds | A string, independent of any account | Nothing — access follows their sign-in |
| Works with no account | Yes | No — requires a session |
| Can be shared or leaked | Yes, and the seller often can't tell | No transferable secret exists to leak |
| Revocation | Invalidate the specific key (requires a key→purchase mapping) | Flip status to "revoked" on the slot row |
| Cross-device access | Enter the key again anywhere | Sign in anywhere; the row is the same row |
| What you're building | A second authentication system, keyed by string | Nothing extra — reuses the account system every other feature already needs |
The account requirement is the real cost, not a hidden one: a buyer who wants the file without creating an account can't have it here, and this dashboard already requires email verification before any download for the same reason. That's a legitimate trade against a key-based flow's zero-friction download — it's just not the trade a license-key tutorial usually frames as a trade at all.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Emailing a license key in plaintext, no expiry | The email itself becomes the leak vector, forever | Prefer account-scoped access with no standalone secret to leak, or expire/rotate keys on request |
| Validating a key client-side before allowing a download link to render | The real file URL is still reachable by anyone who finds it, since client-side checks don't gate a server resource | Gate the actual byte-serving endpoint server-side, keyed to a verified identity |
| Redemption endpoint trusts a client-supplied user id alongside the slot id | A forged request can redeem someone else's slot | Derive the owner from the verified session only, and scope every read/write to it (user_id = auth.uid()) |
| No atomicity on "redeem this slot" | A double-click or two tabs redeems the same paid slot twice | An atomic conditional UPDATE ... WHERE status = 'active' AND redeemed_at IS NULL — the database, not application logic, decides the single winner |
| Treating "has a license key" as proof of purchase forever | A refunded order's key still unlocks downloads if nothing revokes it | Tie revocation to the same event that triggers a refund, the way this codebase's webhook flips status to "revoked" on order_refunded |
Frequently asked questions
Does this storefront use LemonSqueezy's license-key feature?
No — the webhook payloads this codebase handles and tests carry no license-key field, and nothing in src/lib/lemonsqueezy/ reads one. Entitlement is decided entirely by the entitlement_slots table the webhook writes to, not by a key LemonSqueezy could optionally issue.
Isn't requiring an account just friction a license key avoids? For a buyer who wants to download once and never come back, yes. The trade this codebase makes is that the same account also carries redemption (picking which product a Single slot applies to), re-downloads after a lost file, and a dashboard showing everything owned — a key gets you the file faster once, an account gets you all of that every time.
Could a license key and an account coexist?
Architecturally, yes — plenty of products issue a key at purchase and also let you view it in an account dashboard. This codebase didn't build that second surface because nothing here needs a credential that outlives or travels outside the account; adding one would be a second thing to keep in sync with the entitlement_slots table for no capability the account doesn't already provide.
What actually stops someone from sharing their download link? The link itself is short-lived — a signed URL with a 60-second TTL generated fresh per request, not a durable secret. Sharing a specific signed URL only works for the window before it expires; sharing account credentials would work indefinitely, which is a different problem this storefront leaves to normal password hygiene rather than trying to solve architecturally.
Templates where this pattern already ships
ASoc Desk is an office-equipment storefront whose own bundle builder and account pages follow the same pattern — ownership tracked server-side, not a code the shopper carries. ASoc Drape and ASoc Glow round out the set with their own wishlist and account flows built the same way.
Browse the full sets: Next.js shop templates and Tailwind shop templates. For the full download-authorization ordering this entitlement check plugs into, read gated file downloads; for the two-state engine behind status, one-time payment vs. subscription.
