ZestyShop — Devpost Write-Up

Inspiration

Every small business owner we've talked to has the same story: they have a product, they have an audience, but they don't have the engineering team to launch a "real" online store. The freely available e-commerce templates either stop at a product grid and a fake checkout button, or they lock the meaningful features (payment processing, email notifications, order management) behind enterprise pricing.

ZestyShop was inspired by the idea that the gap between "weekend hackathon prototype" and "shippable product" shouldn't exist. With the Vercel + AWS Databases "Zero Stack," the same infrastructure that runs the demo can run production. We wanted to prove that by building an actual e-commerce platform — not a mock — that pushes real money through real payment gateways, sends real transactional emails, and stores data in a database engineered for production scale.

The multi-vendor inspiration came later: once you have the catalog, cart, and checkout working, extending it to let other sellers list their own products on the same platform is a natural next step. That turned ZestyShop from a single-storefront template into a marketplace foundation.

What it does

ZestyShop is a full-featured, multi-vendor e-commerce platform with everything a small business needs to sell online:

For shoppers:

  • Browse and search a full product catalog with category filtering
  • Persistent cart (survives page refreshes) with add/remove/update quantities
  • Secure checkout with shipping address collection and discount code validation
  • Real payment processing via Stripe (subscriptions) and DodoPayments (one-time)
  • User accounts with email/password or Google OAuth (Better Auth)
  • Wishlists and product reviews with verified-purchase badges
  • Order tracking with email notifications at each status change
  • Dark mode with customizable theme colors

For admins:

  • Manage products (CRUD, images via S3 presigned uploads, variants)
  • View and update orders — status transitions (pending → processing → shipped → delivered) trigger automatic shipping notification emails
  • Manage categories, discount codes, site settings, and homepage hero
  • Bulk product import for catalog bootstrapping
  • Theme customization (colors, fonts, background)

For merchants (multi-vendor):

  • Apply to become a merchant (admin-reviewed)
  • Create and manage their own store (name, slug, logo, description)
  • Add product listings with custom pricing, inventory, and special offers
  • Manage shipping profiles (carrier, rates, regions)
  • View marketing data — subscriber list and counts
  • Switch between multiple stores they own

How we built it

Frontend: Next.js 16 with the App Router, mixing React Server Components for content-heavy pages (homepage, product listing, product details, articles, legal pages) with Client Components for interactive surfaces (cart sheet, checkout, account dashboard, admin/merchant dashboards, search, theme toggle). TypeScript strict mode throughout, Tailwind CSS v4 with tw-animate-css for animations, shadcn/ui on Radix for accessible primitives.

State management: Three layers chosen deliberately:

  • Zustand v5 with persist middleware for the cart — localStorage means the cart survives page refreshes, server restarts, and logged-out sessions
  • SWR v2 for server data (orders, wishlist) — built-in caching, revalidation, and optimistic updates
  • Better Auth's session handling for auth state

Database: AWS Aurora PostgreSQL, accessed via the pg driver with connection pooling, and the Kysely query builder for type-safe queries. For complex aggregations (e.g., order listing with nested JSON item aggregation), we fall back to parameterized raw SQL. Database authentication uses Vercel OIDC → AWS STS → RDS IAM tokens — short-lived credentials instead of static passwords.

Auth: Better Auth v1.6 with email/password and Google OAuth providers, the Kysely adapter, and 30-day session expiry. Admin access is gated by checking the session email against an ADMIN_EMAILS env var.

Payments: Stripe (@better-auth/stripe plugin for subscriptions) and DodoPayments (@dodopayments/better-auth for one-time checkout), both wired into the Better Auth flow.

Storage and Email: Amazon S3 for product image uploads using presigned POST (browser talks directly to S3, no proxy through our server), and Amazon SES for transactional emails (verification, password reset, order confirmation, shipping notifications) — with templated HTML emails and SaaS-compatible IAM role-based sending.

CI/CD: GitHub → Vercel auto-deploy on push to main. Database migrations run as part of the build via pnpm vercel-build → pnpm db:setup-full → pnpm build.

Architecture diagram:

Browser → Vercel (Next.js SSR + API)
              ├── Better Auth → Aurora PostgreSQL
              ├── API Routes → Aurora PostgreSQL
              ├── /api/upload → Amazon S3 (presigned)
              ├── Amazon SES → Email
              ├── Stripe SDK → Subscriptions
              └── DodoPayments SDK → Checkout

Challenges we ran into

Better Auth schema name collision: Better Auth creates tables named "user" (singular, quoted), not users. Our initial SQL referenced users and the admin orders endpoint crashed at runtime with relation "users" does not exist (Postgres error 42P01). The fix was a JOIN rewrite to use "user" u ON u.id = o.user_id in both lib/admin/orders.ts and app/api/admin/orders/route.ts, plus updating the corresponding unit-test assertion. Bug shipped, bug found in production, bug fixed — the test wasn't catching it because we were checking that the literal SQL contained the wrong string.

Slot multi-child crashes with asChild: We use Radix Slot via shadcn for buttons rendered as links. When a button has multiple children (e.g., an icon and text), Slot fails because Slot expects exactly one child. Several components — order action buttons, cart items, account dashboard quick-link cards — crashed dev and production alike with cryptic Slot errors. Fix: use Slot.Slottable around composite children, or split into separate single-child components.

Checkout page SSR with empty cart: The checkout page initially broke for first-time visitors because Zustand's persist middleware runs on the client, and the server rendered an empty cart which then mismatched with the hydrated state. We made the page dynamic (export const dynamic = 'force-dynamic') to skip prerender entirely, and made sure all interactive components outside the cart context handle the loading state explicitly.

Build-time database migration vs. production seed idempotency: Running migrations on every deploy is dangerous — if a migration isn't idempotent, a re-deploy causes partial failures. We rewrote every migration to use CREATE TABLE IF NOT EXISTS, ADD CONSTRAINT IF NOT EXISTS, etc. Seed data uses ON CONFLICT DO NOTHING so re-running it doesn't create duplicate rows. The first fix was to make the Better Auth schema migration itself idempotent by adding IF NOT EXISTS directly to scripts/better-auth-schema.sql after discovering the migration script was dropping and recreating tables on every Vercel build.

AWS IAM auth from Vercel: Setting up RDS IAM authentication from Vercel serverless functions required wiring up the Vercel OIDC provider as a trusted identity in AWS IAM, then having the Better Auth PostgreSQL pool obtain a short-lived token from RDS via the @vercel/oidc-aws-credentials-provider on every connection. The first attempt used static AWS access keys, which worked but violated our own security stance — the IAM-only flow took an afternoon to get right but it's the only configuration we ship.

Accomplishments that we're proud of

  • 109 unit and integration tests across 24 test files, plus Playwright e2e suites covering auth, cart, checkout, merchant, admin, orders, storefront, and routing. Coverage includes not just the happy path but the validation branches (invalid status, missing fields, trimming empty strings).
  • Production-grade schema design: 15+ tables with proper foreign keys, check constraints for data integrity (status enums, price ranges, rating bounds), and partial indexes for hot paths (featured products, guest-emailed orders). The schema could ship to a high-traffic store without modification.
  • Full multi-vendor data model: stores, user_stores membership, product listings per-store with custom pricing and inventory, shipping profiles per-store (carrier, rate, region), and store subscription marketing emails.
  • Real payment + email integration: not mocked. A test order flows through Stripe or Dodo, triggers an SES email on shipping, and updates the admin orders dashboard. End-to-end works.
  • Direct-to-S3 uploads: the browser uploads product images straight to S3 using presigned POST. Our server never proxies the file data, which is the right architecture for serverless.
  • Better Auth with two providers (email/password + Google OAuth) wired through the Kysely adapter with no static credentials.
  • Theme customization: every color, font, and background is admin-configurable via the database, applied at runtime via CSS variables — no rebuild.
  • Vercel deployment runs the database migration as part of the build, which means every git push is a full release with schema changes applied.

What we learned

  • Aurora IAM auth from serverless is viable — and worth it. The extra wiring for OIDC + STS pays dividends: no secrets in Vercel env vars, no credential rotation, no compromise surface.
  • Zustand persist + SSR is a footgun. Server-rendering a page that depends on localStorage state creates hydration mismatches. The fix is to either make the page fully client-side, mark it force-dynamic, or render a stable placeholder during SSR and let the client hydrate.
  • Idempotent migrations are non-negotiable when CI runs them automatically on every push. Every CREATE, ALTER, and index needs an IF NOT EXISTS guard, and seeds need ON CONFLICT DO NOTHING. We learned this the hard way when a migration script dropped a table mid-deploy.
  • Radix Slot has strict child rules. asChild is great for polymorphic components, but every child needs to be wrapped in Slot.Slottable if there are multiple. We codified this pattern across the codebase to prevent regressions.
  • Kysely for type-safe queries, raw SQL for complex aggregations. The hybrid approach lets you get the best of both worlds: full type-checking for 80% of your queries, raw SQL for the 20% where JSON aggregation or recursive CTEs are cleaner.
  • Better Auth's BYO-table architecture (singular "user" instead of conventional users) is a subtle gotcha when writing SQL by hand or using ORMs that don't know about the schema.

What's next for ZestyShop

  • Abandoned cart recovery — automated email reminders after X minutes of inactivity, with a one-click checkout link
  • Product variants — size/color/option groups with per-variant pricing, inventory, and SKU
  • Bundle deals and BOGO discount engine — currently we support percentage discount codes, but the schema is ready for tiered, fixed-amount, and rule-based promotions
  • Vendor payouts via Stripe Connect — for the multi-vendor marketplace, automated commission calculation and mass payouts to connected accounts
  • Full-text search via Meilisearch or Typesense — replacing the current wildcard ILIKE queries with proper search ranking, autocomplete, typo tolerance, and faceting
  • CloudFront CDN in front of S3 — lower latency for product images, cheaper egress, automatic image optimization (resize, format conversion, blur placeholders)
  • PgBouncer between Vercel serverless functions and Aurora for connection pooling — currently each cold function establishes a new pooled connection; PgBouncer with transaction pooling will keep connection counts bounded under burst load
  • A Vercel KV (Redis) layer for session caching and rate limiting public endpoints (we currently have no rate limiting, which is a gap the Project.md file flags)
  • GraphQL or tRPC layer — the REST API works, but a type-safe RPC would let the frontend and backend share types and reduce hand-written fetch boilerplate
  • Storybook for the component library — we have 60+ components split across ui/, home/, products/, account/, admin/, merchant/, cart/, checkout/, and visual regression testing would catch styling drift
  • Audit log for admin actions — currently anyone with admin email can mutate state with no audit trail. Adding a write log table (admin_audit_log) for sensitive actions (product delete, order status change, discount code creation) is a 1-day job that closes a real security gap

Built With

  • amazon-ses
  • amazon-web-services
  • aws-aurora-postgresql
  • better-auth
  • deployment-aws-aurora-postgresql-?-primary-database-amazon-s3-?-file-uploads-amazon-ses-?-transactional-email-stripe-?-subscriptions-dodopayments-?-checkout-next.js-16-?-app-router
  • dodopayments
  • kysely
  • next.js-16
  • pg
  • playwright
  • shadcn/ui-on-radix
  • stripe
  • swr-v2
  • tailwind-css-v4
  • typescript
  • vercel
  • vercel-oidc-+-aws-sts
  • vitest
  • zod
  • zustand-v5
Share this project:

Updates

Submission history