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 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.

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
SQLITE_BUSY. Opening lazily also means a build that never reads the catalogue does not need the file to exist.The other starters: Next.js with auth and billing, Astro, and the digital shop.
