SQLite Backend
A real database in one file — the fastest way to run the store with real writes.
SQLite is the shortest path from the demo catalog to a real store. No container, no connection string to get right, no account: the database is a file next to your code, and every write the app can do — admin product CRUD, quote requests, orders, discounts, carts — actually persists.
echo 'DATABASE_URL="file:./dev.db"' >> .env.local
echo 'COMMERCE_BACKEND=sqlite' >> .env.local
echo 'NEXT_PUBLIC_COMMERCE_BACKEND=sqlite' >> .env.local
COMMERCE_BACKEND=sqlite npm run prisma:generate
COMMERCE_BACKEND=sqlite npm run prisma:push
npm run devAdd npm run seed and you have a populated store — 18 products, 6 featured,
the same fixture the demo backend serves. Delete dev.db to start over.
It is the same adapter as Postgres —
src/lib/commerce/server/prisma/index.ts, one CommerceServices implementation over
one set of models. prisma:push creates every table both backends share,
including audit_log (the admin activity trail), newsletter_signups, and the
Auth.js session tables; the Postgres data model
lists them all. Moving to Postgres later means changing two env vars and
running prisma:push again, not rewriting anything.
What it is good for
| Good fit | Look elsewhere |
|---|---|
| Local development with real data | Serverless deploys — Vercel's filesystem is ephemeral and per-instance, so the database would vanish and never be shared |
| Demos and prototypes you can hand over as a folder | Anything with concurrent writers across processes; SQLite takes one writer at a time |
| Small single-server stores on a persistent disk | Managed backups, read replicas, connection pooling |
CI: it is what lets the SQL adapter be tested against a real engine (npm run test:sqlite) |
SQLite replaces where the data lives, not who the visitor is — that is a
separate choice. Firebase Auth is the default, and AUTH_PROVIDER=authjs puts
sessions in this same file instead, which drops the Firebase project entirely.
See authentication.
Two variables, plus the file
DATABASE_URL="file:./dev.db" # relative to the project root
COMMERCE_BACKEND=sqlite # server: run the Prisma adapter on SQLite
NEXT_PUBLIC_COMMERCE_BACKEND=sqlite # browser: route through /api/commerceThe browser never opens the file. It calls POST /api/commerce/<service>/<method>,
which identifies the caller through the configured auth provider and enforces
public/user/admin access per method before touching the database — the same RPC
path every SQL backend uses.
COMMERCE_BACKEND also decides which schema the Prisma CLI loads, so it has
to be set on the CLI commands too, not just in .env.local:
COMMERCE_BACKEND=sqlite npm run prisma:generate
COMMERCE_BACKEND=sqlite npm run prisma:pushRun them without it and you will generate a Postgres client against a SQLite URL, which fails at the first query.
Grant yourself admin
admin.isAdmin(uid) is row existence in admins, keyed by your auth uid. Sign
in once so the uid exists, then:
sqlite3 dev.db "insert or ignore into admins (uid) values ('<your-uid>');"The uid is shown on the /admin access-denied screen.
Why there are two schema files
prisma/schema.prisma (Postgres) and prisma/schema.sqlite.prisma (SQLite)
are identical below their datasource block, and
tests/prisma-schemas.test.ts fails the build if they ever drift.
They are separate files only because Prisma will not read the provider from the
environment: provider = env("DATABASE_PROVIDER") is a validation error, and
prisma.config.ts accepts only url and shadowDatabaseUrl. So the config
file picks the schema by COMMERCE_BACKEND instead:
const isSqlite = process.env.COMMERCE_BACKEND === "sqlite";
export default defineConfig({
schema: isSqlite ? "prisma/schema.sqlite.prisma" : "prisma/schema.prisma",
datasource: { url: env("DATABASE_URL") },
});Add a column to one schema and the drift test tells you to add it to the other.
Category pages
data.categories is promoted into a product_categories join table so a
category page is an indexed WHERE clause rather than a scan, and
countByCategory is a real count. The adapter rewrites a product's rows in the
same transaction as the product, so they cannot drift.
If you created a database before this table existed, backfill it once after
prisma:push:
sqlite3 dev.db "insert or ignore into product_categories (product_id, category) \
select p.id, j.value from products p, json_each(p.data, '\$.categories') j;"Where the two engines differ
Three statements, all behind isSqlite() in the adapter, and all of them the
SQLite side being simpler:
| Postgres | SQLite | |
|---|---|---|
Prefix search (products.search) | Pins the range and sort to COLLATE "C", because the column's default collation is usually linguistic and would make pro-tools match the term protools. | Compares TEXT by bytes already, so the typed client's range query is exact with nothing to pin. |
Stock locking (orders.recordPaidSession) | Takes SELECT … FOR UPDATE on every product the session touches, ordered by id so concurrent sessions cannot deadlock. | Has no row locks and needs none: it serializes write transactions outright, so a read inside the transaction already excludes the other writer. |
Reading Json columns raw | jsonb comes back parsed. | Stored as TEXT, so a raw query returns a string — the SQLite paths read through the typed client instead. |
Everything else — every query, every write, the transaction in
recordPaidSession, the lifecycle events — is one shared implementation.
Tested against the real thing
SQLite is the only backend the test suite can exercise for real, because it
needs no server. tests/sqlite-integration.test.ts creates a throwaway
database, pushes the schema, and drives the actual adapter:
npm run test:sqliteIt covers the JSON body round-trip, the byte-ordered prefix search (including
the pro-tools / protools case), paging cursors, discounts, carts, and
recordPaidSession — recording, stock and variant-stock decrements, and a
retried delivery hitting the unique constraint and rolling back. Since the two
engines share every line outside the three branches above, what passes here is
evidence for the Postgres path too.
Where this suite sits relative to npm test and npm run test:e2e is on the
testing page.
Moving to Postgres later
Nothing in your code changes:
# .env.local
DATABASE_URL="postgresql://user:password@localhost:5432/store"
COMMERCE_BACKEND=postgres
NEXT_PUBLIC_COMMERCE_BACKEND=postgres
npm run prisma:generate
npm run prisma:pushThe data does not migrate itself — export what you need first, or re-seed. See Postgres / Prisma.