Home/Articles

Creation of ZestyShop

A full account of every technical decision, integration, and architectural choice made while building ZestyShop — a full-stack e-commerce platform on Next.js 16.

·9 sections·~15 min read
Stack01 / 09

Foundation — Next.js 16, Tailwind v4 & shadcn/ui

ZestyShop is built on Next.js 16 (App Router) using the latest Turbopack bundler, which is now stable and the default. The project uses Tailwind CSS v4 with design tokens defined directly in globals.css via @theme inline — no tailwind.config.js file exists. All semantic color tokens (--background, --foreground, --primary, etc.) are defined there, referencing an OKLCH-based palette built around a playful brand of pink, yellow, teal, coral and lavender.

UI components come from shadcn/ui — an unstyled, composable component system backed by Radix UI primitives. Only the components actually needed were added via the shadcn CLI rather than installing the entire library. Typography uses Plus Jakarta Sans for headings and Inter for body text, loaded via next/font/google and wired into the CSS theme as --font-heading and --font-sans.

The layout is split into reusable server and client components: Navbar, Footer, ProductCard, CartDrawer, CheckoutForm, and more. Cart state is managed client-side with a Zustand store (lib/cart-store.ts), persisted to localStorage so it survives page refreshes without a round-trip to the server.

Infrastructure02 / 09

Database — Amazon Aurora PostgreSQL with IAM Auth

Transactional data (orders, order items, reviews, wishlists, discount codes) lives in Amazon Aurora PostgreSQL. Rather than a static connection string, the project uses IAM authentication: at connection time, @aws-sdk/rds-signer signs a temporary token using the Vercel OIDC role ARN (AWS_ROLE_ARN), which Aurora accepts as the password. This means no database password is ever stored in environment variables.

The connection pool is created in lib/db.ts using the pg library. A custom withConnection helper wraps transactions: it acquires a client, begins a transaction, runs the callback, commits or rolls back, then releases. All app-level tables are defined in scripts/001-setup-schema.sql and applied with node scripts/run-migration.mjs.

After migrating to Better Auth, the orders, reviews, and wishlists tables were updated to reference "user"(id) as TEXT (Better Auth uses UUIDs) instead of the previous INT SERIAL foreign keys. The old hand-rolled users and sessions tables were dropped entirely.

CMS03 / 09

Content — Sanity CMS

Product content (titles, descriptions, images, prices, tags, categories) is managed in Sanity via a connected MCP server. Schema types live in sanity/schemaTypes/ and GROQ queries in sanity/queries.ts. The sanityFetch helper in sanity/client.ts wraps the Sanity client with CDN caching.

Both the Featured Products section on the homepage and the full Products listing page are async server components that call Sanity at render time. When NEXT_PUBLIC_SANITY_PROJECT_ID is not set or Sanity is unreachable, both pages fall back to a set of 8–12 hardcoded mock products so the storefront always renders, even in a cold development environment without CMS credentials.

The Sanity Studio is embedded in the project at app/studio/[[...tool]]/page.tsx and accessible at /studio when running locally. It is excluded from the production build via environment-gated rendering.

Auth04 / 09

Auth — Better Auth with Email + Google OAuth

Authentication started as a hand-rolled system: custom users and sessions tables, bcryptjs password hashing, four separate Route Handlers (register, login, logout, me) and a lib/session.ts module managing UUID session cookies. After recognising that Better Auth provides rate limiting, teams, permissions, social OAuth, email verification and more out-of-the-box, the entire system was replaced.

Better Auth is configured in lib/auth.ts using the PostgreSQL adapter — it takes the same pg Pool from lib/db.ts directly. Better Auth auto-creates and manages its own user, session, account, and verification tables. A single catch-all Route Handler at app/api/auth/[...all]/route.ts replaces the four old routes.

Google OAuth is enabled via socialProviders.google with prompt: "select_account" so users always see the account picker. The client is a better-auth/react instance (lib/auth-client.ts) exposing authClient.useSession(), authClient.signIn.email(), authClient.signUp.email(), and authClient.signIn.social().

The login UI was consolidated from a two-tab card (separate Sign In / Register tabs) into a single progressive form: enter email first, then the form detects whether the account exists and reveals either a sign-in password field or a name + password + confirm registration flow. The "Continue with Google" button sits at the top of both states with an SVG logo sourced from theSVG.org.

Built-in rate limiting is enabled (10 requests per 60 seconds per IP) and email verification is required on sign-up (requireEmailVerification: true, sendOnSignUp: true).

Email05 / 09

Email — Amazon SES (af-south-1)

Transactional emails are sent through Amazon SES in the af-south-1 region using the same IAM credentials provider already in the project (@vercel/functions/oidc + AWS_ROLE_ARN). A dedicated SES_REGION environment variable keeps the SES region separate from the Aurora AWS_REGION to avoid conflicts.

lib/email.ts exports three functions — sendVerificationEmail, sendResetPasswordEmail, and sendOrderConfirmationEmail — each rendering a polished HTML template with the ZestyShop brand colours, a heading, body copy, and a primary CTA button. The order confirmation template also includes an itemised product table with quantities and prices.

The sending domain is zesty-shop.metafor.co.za. The DNS is managed on Cloudflare. Because SES domain verification does not cascade to subdomains, the subdomain was verified separately in SES, generating three DKIM CNAME records (*._domainkey.zesty-shop.metafor.co.za → *.dkim.af-south-1.amazonses.com) added to Cloudflare with proxy disabled. The FROM address reads from SES_FROM_ADDRESS (set to noreply@zesty-shop.metafor.co.za).

Better Auth calls sendVerificationEmail automatically on every email/password sign-up. The orders POST route calls sendOrderConfirmationEmailfire-and-forget after a successful transaction commit, targeting either the authenticated user's email or the guest shipping email.

Commerce06 / 09

Checkout & Orders

The cart is a Zustand store with line-item add/remove/update, quantity management, and a discount code system. Discount codes are stored in the Aurora discount_codes table and validated server-side via GET /api/discount-codes/[code] before being applied client-side.

POST /api/orders creates the order inside a single Aurora transaction: BEGIN → insert orders row → insert all order_items rows → COMMIT. If the Aurora connection is not available, the route returns a 503 with a descriptive error rather than crashing. Orders support both authenticated users (linked via user_id) and guest checkout (stored via guest_email).

After the transaction commits, an order confirmation email is dispatched fire-and-forget via SES so it never delays the 201 response back to the client.

Reliability07 / 09

Resilience & Error Handling

A recurring theme in the build was making the app gracefully degrade when optional infrastructure is not connected. Key patterns used throughout:

  • Sanity fallback — product pages check for NEXT_PUBLIC_SANITY_PROJECT_ID before fetching; missing or throwing falls back to mock data silently.
  • Session resilience — all Aurora calls in the auth layer are wrapped in try/catch, returning null rather than propagating a 500 when the DB is unavailable.
  • SWR hooksuseAuth and useOrders both set shouldRetryOnError: false and handle 401 responses as empty state rather than throwing, preventing infinite retry loops in the console.
  • Orders API — checks for PGHOST presence and returns a clear 503 with a message instead of a cryptic crash when Aurora is not configured.
DNS08 / 09

Domain & DNS — Cloudflare + Vercel

The store runs at zesty-shop.metafor.co.za, with DNS managed on Cloudflare. The subdomain points to Vercel (CNAME to cname.vercel-dns.com). A key lesson from the DNS setup: Cloudflare's proxy (orange cloud) must be disabled for any DNS record that SES or AWS needs to do a direct lookup on — specifically the three DKIM CNAME records. Proxying those records causes SES domain verification to fail silently.

The parent domain metafor.co.za already had SES DKIM, SPF, and DMARC configured correctly. Because SES does not inherit verification for subdomains, zesty-shop.metafor.co.za was verified as a separate identity in the SES console, generating its own set of DKIM signing keys.

Payments09 / 09

Payments — Stripe & Dodo Payments via Better Auth

Rather than building raw webhook handlers and checkout session routes by hand, ZestyShop uses the official Better Auth payment plugins: @better-auth/stripe and @dodopayments/better-auth. Both are registered directly in the betterAuth() config plugins array, meaning customer creation, session linking, subscription management, and webhook verification all run through the same auth layer rather than being bolted on separately.

Stripe handles card payments, Apple Pay, and Google Pay globally. The plugin automatically registers a /api/auth/stripe/* route group for webhook ingestion and subscription management. Two subscription plans — Starter and Pro — are defined via Stripe Price IDs (STRIPE_STARTER_PRICE_ID, STRIPE_PRO_PRICE_ID). Webhook signature verification uses STRIPE_WEBHOOK_SECRET. The Stripe client is instantiated with API version 2026-05-27.dahlia.

Dodo Payments adds African local payment methods and is configured in test_mode. The plugin uses a composable use array — checkout() for hosted session creation and webhooks()for event handling. Webhook verification uses the Standard Webhooks scheme (not Stripe's custom format), with the signing key passed as webhookKey. Event handlers fire for onPaymentSucceeded, onSubscriptionActive, and onSubscriptionCancelled.

The /pricing page lets users choose between Stripe and Dodo before subscribing — a gateway toggle sits above the plan cards. Checkout is initiated via authClient.$fetch to the respective Better Auth endpoint, which returns a hosted checkout URL. A shared usePaymentToast hook reads the ?payment=success|cancelled query param after redirect and fires a Sonner toast before cleaning the URL — no separate success/cancel page needed.

A subtle bug caught during setup: BETTER_AUTH_URL was set without the https:// prefix, causing an Invalid URL crash at server startup. A defensive ensureProtocol() helper was added in lib/auth.ts to prepend the protocol if missing, making the app resilient to misconfigured environment variables.

Built with v0

Every component, integration, migration, and architectural decision described above was implemented iteratively in a single v0 chat session, with human review and approval at each significant step.