All examples

Testing a Next.js ecommerce store with e2e

Catalog, search, bag, wishlist, account, and admin tests for a Next.js store, with agent steps run through the Claude CLI.

Community examples aren't actively verified by the e2e team so exercise extra caution when running.

Storefront starter with a public catalog, authenticated cart and checkout, Stripe fulfillment, and an admin catalog that stays in sync with Stripe. Built for App Router, a least-privilege Postgres layout, and a Principal-bound data layer.

Live demo: ecommerce-template-mpc.vercel.app

Stack

LayerChoice
AppNext.js 16.3 (App Router, Cache Components), React 19, TypeScript
UITailwind CSS, Radix UI, TanStack Query
AuthBetter Auth (email/password; Google is opt-in)
DataDrizzle ORM on Supabase PostgreSQL (app_private)
PaymentsStripe Checkout (server-priced line items)
MediaSupabase Storage (product-images: JPEG, PNG, WebP)
MailNodemailer over explicit SMTP (optional)
ValidationZod

Shared domain language lives in CONTEXT.md. Architecture and invariants are in docs/architecture/verifiable-auth-data-fulfillment.md.

Features

  • Catalog with categories, variants, search, and help pages
  • Cart and wishlist (session required; data is scoped to the signed-in Principal)
  • Stripe Checkout from a server-built cart snapshot, not a client-supplied price
  • Order history and a result page that only loads a session the buyer owns
  • Admin product create/edit with Stripe catalog sync and replay for failed work
  • Background fulfillment and catalog-sync crons (Vercel, every 5 minutes)
  • Optional order-confirmation and merchant-notification email

Prerequisites

  • Node.js 24 (what CI uses) and npm
  • PostgreSQL 15 or newer, typically via Supabase
  • A Stripe account, or the local Stripe emulator for development

Getting started

  1. Install dependencies:

    Terminal
    npm install
  2. Copy .env.example to .env.local and fill the values described below.

  3. For a fresh database, bootstrap roles, migrate, verify, then promote the first admin. For a populated database, follow the database cutover runbook instead of applying ad-hoc schema changes.

    Terminal
    npm run db:migrate
    npm run db:verify
    npm run auth:bootstrap-admin
  4. Start the app:

    Terminal
    npm run dev
  5. Open http://localhost:3000.

db:push is disabled. Generate a reviewed migration with db:generate, apply it with db:migrate.

Configuration

Keep NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL on the same canonical origin. APP_URL is an optional server-only override; when set, it must use that same origin.

The complete list of variables is in .env.example. These are the ones that decide how the app boots:

VariableRole
DATABASE_URLRuntime connection as app_runtime
MIGRATION_DATABASE_URLapp_migrator; db:migrate then assumes app_owner
VERIFY_DATABASE_URLLeast-privileged connection for db:verify
AUTH_DATABASE_LAYOUTMust be app_private
BETTER_AUTH_SECRETSession signing secret
ADMIN_USER_IDBetter Auth user id promoted by auth:bootstrap-admin
CRON_SECRETBearer secret for /api/cron/*
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYStripe
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEYStorage and project URL
SUPABASE_PROJECT_REF, SUPABASE_ACCESS_TOKENHosted Data API / Storage apply (db:apply-hosted)

ADMIN_BOOTSTRAP_DATABASE_URL is only for the temporary legacy-public cutover in the runbook. Do not put role= in MIGRATION_DATABASE_URL.

Auth

Email/password registration opens a session immediately. The app does not send or require a verification email, because SMTP is optional and often unset.

Google sign-in is opt-in. Register the exact callback http://localhost:3000/api/auth/callback/google (and the HTTPS production equivalent) in Google Cloud, set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, then set both GOOGLE_AUTH_ENABLED=true and NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true. The server fails closed when the flags differ. Keep both false until the callback is registered.

OAuth cannot reclaim or rewrite an unverified credential account. Linking another provider still requires the existing local account to be verified.

Promote the first administrator by user id, not by email:

Terminal
ADMIN_USER_ID=<better-auth-user-id> npm run auth:bootstrap-admin

Email

Delivery is disabled until EMAIL_SERVER_HOST names an explicit SMTP transport. A configured host also needs a valid port, user, password, sender (EMAIL_FROM), and contact recipient (EMAIL_CONTACT_TO or ADMIN_EMAIL). Partial configuration fails before Nodemailer creates a transport. Gmail can be used with its SMTP host; credentials alone never enable a Gmail fallback.

Without SMTP, sign-up still works. Order confirmation and merchant notification emails do not.

Local Stripe

In development, set STRIPE_EMULATOR_URL to a loopback HTTP origin (no path, credentials, or query). Example:

Terminal
npx emulate --service stripe

Then point STRIPE_EMULATOR_URL at the emulator origin (default http://localhost:4000 when Stripe runs alone). The variable is rejected in NODE_ENV=production.

The live webhook endpoint is /api/stripe/webhooks.

Scripts

ScriptPurpose
npm run devNext.js dev server
npm run build / npm startProduction build and server
npm testNode.js test runner
npm run test:e2eEnd-to-end tests in a browser; see End-to-end tests
npm run lintESLint
npm run typechecktsc --noEmit
npm run verify:architectureImport-boundary and layering checks
npm run verify:releaseProduction evidence gate
npm run db:generateDrizzle Kit migration
npm run db:migrateRole-aware migrator (app_migrator → app_owner)
npm run db:verifySchema and privilege verifier
npm run db:apply-hosted / db:verify-hostedPush and check Supabase Data API + product-images
npm run db:mark-cutoverRecord applied migrations after a cutover
npm run db:studio / db:pullDrizzle Kit as app_owner via the migrator session
npm run auth:bootstrap-adminGrant admin to ADMIN_USER_ID
npm run doctorReact Doctor on the full tree

db:migrate authenticates as app_migrator, proves that role cannot run private DDL directly, then SET ROLE app_owner before applying the official Drizzle journal.

db:apply-hosted uses the lockfile-pinned Supabase CLI to push supabase/config.toml to SUPABASE_PROJECT_REF, then verifies the Data API allowlist and the public product-images bucket (5 MiB, JPEG/PNG/WebP). It needs SUPABASE_ACCESS_TOKEN and SUPABASE_SERVICE_ROLE_KEY. The service-role key is withheld from the CLI config-push subprocess.

End-to-end tests

tests/ holds a browser suite written with e2e, and an agent does most of it. A test says what a shopper wants ("add this piece to the bag in size M"); the agent does it in a real browser and is then asked whether it happened. No goal says how a page is built and no check names a label of it, so a redesign that keeps the store working keeps the suite green.

  • What the shopper sees is judged by the agent, in words.
  • What the store recorded (the lines of a bag, a saved piece, who is signed in) is read from its API and compared exactly: money and quantities are not left to a model.
  • A few tests are exact from end to end, because the exact interface is their subject: the keyboard, focus, notices that come and go, who may see what. Those name today's labels and say so.

It assumes no catalog: it reads the pieces the store shows and works with those.

The agent is Claude, through the Claude Code CLI and the account claude is signed in to (the bridge); no key is involved. The first run asks the model for every goal. A goal whose outcome was confirmed is recorded in .e2e/cache/ and replayed afterwards without the model, which takes over again only where the interface has changed. The judgments are asked on every run: about 75 calls and four to five minutes for the whole suite.

Start the store, then run the suite against the origin the store is served from (APP_URL; the proxy redirects any other host):

Terminal
E2E_APP_URL=http://localhost:3000 npm run test:e2e
VariableEffect
E2E_APP_URLThe store under test. Default http://localhost:3000.
E2E_ALLOW_WRITES=1Also runs the tests that sign up shoppers, fill bags and save pieces. Only against a disposable database. Without it a run changes nothing.
E2E_USER_CUSTOMER_USERNAME, E2E_USER_CUSTOMER_PASSWORDA shopper with past orders, for the order pages.
E2E_USER_ADMIN_USERNAME, E2E_USER_ADMIN_PASSWORDAn admin, for the product forms. They are opened, never saved.
E2E_MODELThe model the CLI runs. Default sonnet.
  • The tests tagged agent need the CLI. The rest need nothing: npm exec -- e2e run --exclude-tag agent.
  • The catalog needs pieces in every section, in stock in two sizes. The tests of colors and of the photo viewer skip when no piece has them.
  • The cards of a listing are read straight from the page. If a redesign leaves that reading with nothing, the agent reads the listing instead; the suite gets slower, not red.
  • A production build allows three sign-ins every ten seconds. The suite opens its sessions through the auth API and waits a refusal out.
  • Checkout is covered up to the hand-off: the test stands in for Stripe.
  • A run writes its report, traces and replay cache to .e2e/, which is ignored.

Architecture

src/lib/identity is the only request-credential boundary. It returns opaque user Principals (or purpose-limited system Principals for fulfillment and catalog sync). UI role checks are advisory; writes repeat the authoritative check.

src/lib/data-access is the application interface to repositories. Public catalog reads are separate from Principal-bound cart, wishlist, order, checkout, and admin operations.

Checkout persists a server-created snapshot (at most 100 lines). Stripe metadata holds only an opaque checkout-intent id. Fulfillment reconciles the charged amount against that snapshot; the live cart is never a fulfillment input. Webhooks record an event receipt and work row, then a worker (or the 5-minute cron) completes the order.

Deployment

  • Set every required variable in the host (Vercel Production, Preview, and Development as needed).
  • Point Stripe webhooks at https://<origin>/api/stripe/webhooks.
  • Run npm run db:migrate before shipping code that depends on a new schema.
  • Never use db:push.
  • Production builds run npm run verify:release and fail until RELEASE_CUTOVER_EVIDENCE, RELEASE_HOSTED_EXPOSURE_EVIDENCE, and RELEASE_CREDENTIAL_ROTATION_EVIDENCE point at the approved restore rehearsal, hosted exposure report, and credential-rotation audit.
  • Vercel crons call /api/cron/fulfillment and /api/cron/catalog-sync every five minutes. Both require CRON_SECRET.

CI

Pull requests run .github/workflows/ci.yml: tests, a PostgreSQL 15 cutover rehearsal, typecheck, lint, architecture and release-gate checks, next build, and npm audit (all dependencies and production-only).

License

MIT. See LICENSE.