Skip to main content
ASoc
Guide

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.

The ASoc Team9 min read

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 keyThis codebase's account-scoped slot
What the buyer holdsA string, independent of any accountNothing — access follows their sign-in
Works with no accountYesNo — requires a session
Can be shared or leakedYes, and the seller often can't tellNo transferable secret exists to leak
RevocationInvalidate the specific key (requires a key→purchase mapping)Flip status to "revoked" on the slot row
Cross-device accessEnter the key again anywhereSign in anywhere; the row is the same row
What you're buildingA second authentication system, keyed by stringNothing 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

MistakeSymptomFix
Emailing a license key in plaintext, no expiryThe email itself becomes the leak vector, foreverPrefer 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 renderThe real file URL is still reachable by anyone who finds it, since client-side checks don't gate a server resourceGate the actual byte-serving endpoint server-side, keyed to a verified identity
Redemption endpoint trusts a client-supplied user id alongside the slot idA forged request can redeem someone else's slotDerive 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 twiceAn 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 foreverA refunded order's key still unlocks downloads if nothing revokes itTie 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.

Keep reading

Guide10 min read

React vs Next.js Template Editions: Which One to Start From

Every other comparison asks which framework to build in. This asks which folder to open after you have bought the design — and why the licence usually covers both.

Read more
Guide9 min read

Should I Hire a Web Designer? Six Defects a Comp Cannot Contain

Hire for direction, not for pictures of a decision you already made. Six real defects found on a site scoring accessibility 100 — none of them visible in any comp.

Read more