Merch App Architecture

Stack, dependencies, infrastructure, and design decisions

Stack

LayerTechnologyLocation
FrontendVite + React 18 + React Router v7apps/zooly-merch/ (thin wrapper)
Client logicReact components, hooks, pagespackages/merch/client/src/
Backend APINext.js 16 API routesapps/zooly-app/app/api/merch/
Server logicTypeScript servicespackages/merch/srv/src/
Payment integrationStripe via @zooly/srv-stripe-paymentpackages/srv-stripe-payment/
DatabasePostgreSQL + Drizzle ORMpackages/db/src/schema/merch*.ts
Access layerTyped functionspackages/db/src/access/merch/
TypesShared interfaces/enumspackages/types/src/types/Merch.ts
Payments (client)Stripe PaymentElementVia @stripe/react-stripe-js
Image processingSharp (compositing) + FAL.AI (generation)packages/merch/srv/src/
EmailSendGridpackages/merch/srv/src/send-email.ts

Architecture Diagram

apps/zooly-merch/ (Vite SPA — thin wrapper)
  src/
    main.tsx             ReactDOM entry point
    styles/              CSS + Tailwind @source directive

packages/merch/client/ (Client Package — @zooly/merch-client)
  src/
    app/pages/           15 page components (lazy-loaded)
    components/          22 reusable components
    context/             MerchContext provider
    hooks/               6 custom hooks
    lib/                 api client, types, event logging
    styles/              CSS custom properties + utilities

apps/zooly-app/app/api/merch/ (Next.js API — thin routes)
  20 route files + cors.ts

packages/merch/srv/ (Server Package — @zooly/merch-srv)
  25 service files (AI, image processing, orders, email, lifecycle)

packages/srv-stripe-payment/ (Payment Service — @zooly/srv-stripe-payment)
  createMerchPaymentIntent.ts   Merch-specific PI creation
  getProductByPayForId.ts       MERCH case for product resolution

packages/db/src/ (Database)
  schema/merchTables.ts    14 tables
  schema/merchEnums.ts     17 enums
  access/merch/            11 access files

Why a Standalone Vite SPA?

The merch app is not a Next.js page or an enum-based screen like the main zooly-app. It's a standalone Vite SPA because:

  1. Separate deployment at merch.zooly.ai via reverse proxy
  2. No SSR needed for the fan-facing flow
  3. Campaign-specific theming via --merch-* CSS custom properties and primaryColor injection
  4. DM Sans font (not the Inter/Outfit used by the main app)
  5. Frontend code in @zooly/merch-client — app is a thin wrapper that mounts the client package

The pattern follows apps/zooly-ops/ (Vite + React, client-only), not apps/zooly-app/ (Next.js).

Frontend Dependencies

Runtime

PackagePurpose
react-router 7.13Client-side routing
@stripe/react-stripe-js ^3.1Stripe PaymentElement
@stripe/stripe-js ^5.5Stripe.js loader
@radix-ui/react-dialogLightbox dialog
@radix-ui/react-selectCountry/state dropdowns
class-variance-authorityButton/component variants
clsx + tailwind-mergeClass merging
lucide-reactIcons
motionFramer Motion animations
sonnerToast notifications

Dev

PackagePurpose
vite 6.3Build tool
tailwindcss 4.1Utility CSS (via Vite plugin)
@playwright/testE2E testing

State Management

ToolLocationPurpose
MerchContextpackages/merch/client/src/context/merch-context.tsxCampaign config + session state
useCart hookpackages/merch/client/src/hooks/use-cart.tsMulti-item cart operations (add/remove/clear)
sessionStoragemerch_session_{slug}Session persistence across page refreshes

MerchContext uses optimistic updates with a 500ms debounced PATCH to the backend. Pending fields are accumulated in a ref and synced in batch. The useCart hook wraps cart-specific logic on top of the context, managing cartItems in the session with support for adding, removing, and clearing items, as well as tracking the "add another" flow state.

CORS Configuration

All merch API routes are public (no auth). Cross-origin requests are handled by:

  1. cors.ts (route-level): validates origin against ALLOWED_DOMAINS_CORS env var
  2. middleware.ts (global): intercepts all /api/* requests for preflight OPTIONS

Allowed origins: localhost:3008 (dev) and merch.zooly.ai (prod).

Key Design Decisions

DecisionRationale
React Router v7 (not enum screens)Multi-page flow with URL state, shareable links
Single /create route (was /loading, /result, /reselfie)One URL for generation → art pick → cart; fewer navigations, reselfie as overlay, goToCreate() replaces three helpers
Stripe PaymentElement (not individual card fields)Modern Stripe best practice, handles Apple/Google Pay
Payment via @zooly/srv-stripe-paymentAudit trail, idempotency, webhook fallback, revenue distribution
Client in packages/merch/client/App is thin wrapper; logic lives in a reusable package
Server in packages/merch/srv/Same separation — business logic outside the Next.js app
--merch-* CSS custom propertiesCampaign-specific theming via primaryColor override
Debounced session sync (500ms)Reduces API calls during rapid interactions
Logarithmic progress (TAU=21700)Realistic feel: fast start, slow approach to 90%
startGenerationEarly on product selectAI generation starts before size selection
Lifecycle polling every 30sBalance between freshness and API cost
Optimistic updates with re-queue on failureResponsive UI even with network issues
Multi-candidate art selectionFan picks from all generation candidates, not just the best
Multi-item cart via useCart hookSession-persisted cart with add/remove/clear/add-another
Reselfie as inline overlay on /createRe-capture selfie without a separate route or leaving generation/selection context
?add=1 query param for add-anotherCross-page state for multi-item cart flow