Production storefront for Thavare, an Indian Ayurvedic skincare brand. It is a headless Next.js front end over Shopify: Shopify holds the catalogue, customers and order records, while checkout, payment, shipping, GST invoicing and warehouse operations are handled by this application.
The reason for the split is that Shopify's own checkout does not fit the requirements here — Razorpay as the payment gateway, Shiprocket serviceability quoted before payment, and Indian GST invoices with per-financial-year numbering. Orders are therefore created in Shopify only after the Razorpay signature verifies, and a local Turso (libSQL) table owns the shipment lifecycle.
- Headless catalogue from the Shopify Storefront API, with product copy, badges, ingredients and
usage instructions read from Shopify metafields and normalised in
lib/shopify-mapper.ts. - Razorpay checkout with server-side HMAC-SHA256 signature verification, constant-time comparison, and two-layer idempotency (local shipments table first, recent Shopify orders as fallback).
- Shiprocket integration: pincode serviceability and shipping cost quoted pre-payment, order creation, courier assignment and pickup scheduling, AWB tracking, label generation, cancellation.
- Shipment state machine (
lib/shipment-transitions.ts) mapping Shiprocket webhook statuses onto a forward-only transition whitelist with terminal states, so out-of-order webhooks cannot regress an order. - Daily Vercel cron that retries failed or stuck shipments up to a per-record
maxRetries, with reassignment to another courier on pickup failure and email alerts to ops. - GST invoices rendered from real HSN codes and per-product rates, splitting CGST/SGST from IGST by whether the customer is in the origin state, with invoice numbers issued from an atomic per-financial-year counter.
- Token-authenticated ops dashboard (
/ops) for acknowledging, packing, labelling, cancelling and refunding orders, guarded by a dedicated JWT secret in middleware. - Customer accounts via Shopify customer access tokens: registration, login, password recovery and reset, saved addresses, order history, and a wishlist stored in a customer metafield.
- Per-request CSP nonce with
strict-dynamic, plus HSTS, frame denial and referrer policy, applied inmiddleware.ts. The nonce is passed to Razorpay's injected script rather than relaxing the policy tounsafe-inline. - Rate limiting on every public write route, backed by Upstash Redis in production and falling back to an in-memory store locally so development needs no Redis.
Browser (React 19 / Next 16 App Router)
│
├─ catalogue ──────────────► Shopify Storefront API (GraphQL, 2025-01)
│ products, variants, metafields, customer auth
│
└─ checkout
│ POST /api/shipping/check → Shiprocket serviceability + rate
│ POST /api/discount → Shopify discount code lookup
│ POST /api/razorpay/order → Razorpay order (amount recomputed server-side)
│ Razorpay modal
▼
POST /api/razorpay/verify
├─ verify HMAC signature (timing-safe)
├─ idempotency: shipments table → Shopify recent orders
├─ create Shopify order (Admin API)
├─ insert shipments row, create Shiprocket order, assign courier
└─ emit Klaviyo event
Shiprocket ──► POST /api/webhooks/shipping/[token]
├─ map status, validate transition
└─ on "delivered" → fulfil Shopify order
Vercel cron ──► GET /api/cron/retry-shipments (daily, Bearer CRON_SECRET)
| Path | Contents |
|---|---|
app/ |
App Router pages, storefront routes, /ops dashboard, /api route handlers |
components/ |
Storefront UI — home sections, product, cart, shop, layout, shared primitives |
lib/ |
Integration clients (shopify, shopify-admin, shiprocket, razorpay, klaviyo), Zustand stores (cart, wishlist, auth), GST logic, rate limiting, sanitiser, Sentry reporter |
lib/db/ |
Drizzle schema (shipments, invoice_counters) and the libSQL client |
drizzle/ |
Generated SQL migrations and snapshots |
e2e/ |
Playwright specs, including a suite that runs against the live site |
docs/ |
Design specs and implementation plans kept alongside the code |
Editorial content — journal articles and the ingredient glossary — lives in typed modules
(lib/journal.ts, lib/ingredients.ts) rather than a CMS, since it changes rarely and benefits
from being type-checked and statically rendered.
npm install
cp .env.example .env.local # fill in real values
npx drizzle-kit push # apply the Drizzle schema to Turso
npm run dev # http://localhost:3000lib/shopify.ts throws at import time when the Shopify variables are missing, so the app will not
boot without at least NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN and
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN.
Variable names required by .env.example (values omitted):
| Group | Variables |
|---|---|
| Shopify | NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN, NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN, SHOPIFY_ADMIN_ACCESS_TOKEN |
| Payments | NEXT_PUBLIC_RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET |
| Shipping | SHIPROCKET_EMAIL, SHIPROCKET_PASSWORD, SHIPROCKET_CHANNEL_ID, SHIPROCKET_PICKUP_LOCATION, SHIPROCKET_WEBHOOK_SECRET |
| Database | TURSO_DATABASE_URL, TURSO_AUTH_TOKEN |
| Ops | OPS_JWT_SECRET, OPS_USERS, OPS_ALERT_EMAIL, CRON_SECRET |
| Marketing / analytics | NEXT_PUBLIC_KLAVIYO_PUBLIC_KEY, KLAVIYO_PRIVATE_API_KEY, KLAVIYO_LIST_ID, NEXT_PUBLIC_GA_MEASUREMENT_ID |
| Optional | UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_BRAND_VIDEO_URL |
The contact form additionally reads WORKSPACE_EMAIL and WORKSPACE_APP_PASSWORD for its
Nodemailer transport; these are not listed in .env.example.
| Method | Route | Purpose |
|---|---|---|
| POST | /api/razorpay/order |
Create a Razorpay order; server recomputes the payable amount |
| POST | /api/razorpay/verify |
Verify signature, create Shopify order and shipment record |
| POST | /api/shipping/check |
Shiprocket serviceability and rate for a 6-digit pincode |
| POST | /api/discount |
Validate a Shopify discount code |
| POST | /api/stock-check |
Live availability for up to 20 variant GIDs |
| GET | /api/search |
Flattened product list for the search modal |
| POST | /api/track-order |
Order status by order number plus email |
| GET | /api/invoice/[token] |
Invoice data; token is base64url(orderName:razorpayPaymentId) |
| POST | /api/reviews/submit |
Submit a review (customer token required) |
| POST | /api/subscribe, /api/contact |
Klaviyo subscription; support email |
| POST | /api/account/* |
Orders, addresses, wishlist, cancel-order — Bearer customer token |
| POST | /api/ops/* |
Dashboard actions — ops-token cookie required |
| POST | /api/webhooks/shipping/[token] |
Shiprocket status webhook, token compared to the secret |
| GET | /api/cron/retry-shipments |
Retry stalled shipments; Authorization: Bearer CRON_SECRET |
| GET | /api/health |
Shopify, Shiprocket and Turso probes; always 200, status ok/degraded |
/api/health deliberately returns 200 even when degraded, so an uptime check or load balancer does
not pull the site out of rotation because one third-party dependency is slow.
Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · Drizzle ORM on Turso/libSQL · Zustand · jose (JWT) · Razorpay · Shiprocket · Shopify Storefront and Admin APIs · Klaviyo · Upstash Redis · Nodemailer · deployed on Vercel.
Unit tests with Vitest (jsdom) cover the cart store, the Shopify product mapper, shipment transitions, box/weight packing and the Shiprocket client:
npm run test # watch mode
npm run test:run # single run
npm run lintPlaywright end-to-end specs live in e2e/ and are not wired to an npm script:
npx playwright test # against http://localhost:3000
npx playwright test e2e/live-site.spec.ts # runs against https://thavare.comNot currently licensed for reuse.