Starter kit

A shop where what you own is an entitlement, not a row

The third starter is a shop: rekey-dev/nextjs-commerce. Next.js, a SQLite catalogue, Rekey for accounts and payments, and one idea that is worth more than the code around it.

The demo storefront showing four digital products in a grid with prices.
Four products, read from a SQLite file. Clone, seed, run.

The catalogue and the ownership record are separate systems

SQLite holds what a product is: name, copy, price, which file it ships. Rekey holds who paid, and answers “does this person own it” as an entitlement.

The temptation is an orders table. It is five minutes of work and it feels like the shop owning its own data. Then a refund happens, or a chargeback, or a card expires, and there are two answers to the same question with no rule about which one wins. The shop keeps serving files to somebody who got their money back, and nothing anywhere looks broken.

So the app opens the database read-only. It physically cannot write an ownership fact. The seed script is the only writer.

lib/entitlements.ts

export async function ownedKeys(): Promise<Set<string>> {
  const session = await auth();
  if (!session) return new Set();

  try {
    const { features } = await rekey().billing.getEntitlements(session.accessToken);
    return new Set(Object.entries(features).filter(([, v]) => v !== false).map(([k]) => k));
  } catch {
    // A billing outage should not hand out files. Fail closed.
    return new Set();
  }
}

There is no cart, and that is the honest version

Rekey checkout sells a plan, so four things in a basket would be four checkouts and four card entries. For downloads, fonts, presets, books and licences that is usually the right shape anyway, which is why the starter is a digital shop rather than a general one.

Each product maps to a plan, and buying it grants an entitlement. It is written at the top of the README rather than left for somebody to discover halfway through a build, and what multi-item checkout would actually take is written down as an issue.

A product page for a monospace font, priced at seventy-nine dollars, with a buy button in a sidebar.
One product, one checkout. The button becomes In your library once the entitlement lands.

The one route where getting it wrong costs money

The download route checks the entitlement itself rather than trusting the page that rendered the link, because a link is a string and anyone can type one. It also resolves the path inside downloads/ and refuses anything that escapes, so a bad row in the catalogue cannot become an arbitrary file read.

app/api/download/[slug]/route.ts

const owned = await ownedKeys();
if (!owned.has(product.entitlementKey)) return new Response('Not yours', { status: 403 });

const root = path.join(process.cwd(), 'downloads');
const file = path.resolve(root, product.file);
if (!file.startsWith(root + path.sep)) return new Response('Not found', { status: 404 });

For real files, put them behind signed object-storage URLs instead of streaming from the app server. The check stays exactly the same; only the last three lines change.

When a file stops being enough

SQLite is right for a starter and wrong the moment somebody who is not you needs to edit product copy. Only lib/db.ts knows where products come from, and it is thirty lines, so moving the catalogue into a CMS leaves everything else alone. Payload is the natural fit and a Rekey plugin for it is on the list.

One SQLite detail worth stealing

The connection opens on first query rather than at import. A connection opened at module scope is opened by every one of Next’s build workers at once, and the first pragma they race on fails with SQLITE_BUSY. Opening lazily also means a build that never reads the catalogue does not need the file to exist.
A digital shop on Next.js and SQLite where ownership is an entitlement | Rekey