HEIR sells perfume at ₹4,500 a bottle, and the store could not look like a Shopify theme. So Shopify keeps the products, orders and payments, and I built everything in front of it.
- Client
- HEIR
- Platforms
- Headless Shopify · Web
- Timeline
- Live, with releases since
- Year
- 2026

6
Products in the first collection
4.8★
Judge.me rating on the flagship scent
2
Scheduled jobs keeping Shopify in sync
HEIR is a new perfume house launching its first collection into a market where nobody knows the name yet. A bottle is ₹4,500 and the discovery set is ₹1,200, so the site has to carry the whole argument for the price: how the bottle is made, what the notes are, who composed it. A standard Shopify theme could not do that — the pages HEIR needed are long, dark, slow-moving editorial things, not a product grid with a sidebar. But the client still had to run the shop from the Shopify admin, because the person adding a product and fulfilling an order is not a developer and should never have to open a code editor. So the real problem was splitting the store in two: Shopify keeps products, inventory, orders and checkout, and everything a customer sees is mine. The second problem was accounts. Shopify's own customer accounts come with Shopify's own login screens, and HEIR wanted sign-in to happen on the site, in the site's own type.
- 01
The storefront is Next.js 15 on the App Router, reading Shopify through the Storefront API and writing through the Admin API. Nothing about the catalogue is hardcoded: products, collections, blog articles and the shop's localisation all come from Shopify at request time.
- 02
Every Shopify read is wrapped in Next's use cache with a tag and a lifetime, so product and collection queries are cached for days and localisation for hours. A Shopify webhook hits /api/revalidate, which calls revalidateTag for products or collections — the client edits a price in the admin and the site follows without a deploy.
- 03
Shop filters are driven by a naming convention rather than code. Collections whose handle starts with collection- become the category filters and ones starting with family- become the olfactive families — Ambery Oud, Aromatic Woody, Floral Fruity Musk, Fruity Floral, Oriental Gourmand. Adding a sixth family is a new collection in Shopify, not a release.
- 04
Product pages are assembled from Shopify metafields and metaobjects: notes_accords, perfumer, ingredients, product_details, shipping_information, return_policy. The accordion on a product page is built from whatever metafields exist, with defaults underneath, so a product with no perfumer note renders cleanly instead of leaving a gap.
- 05
Pricing is multi-market. Middleware reads Vercel's x-vercel-ip-country header into a country cookie, the switcher writes a country_override cookie that always wins, and both feed Shopify's @inContext directive. The country list and currencies come from Shopify's Markets settings at runtime, so a new market is a dashboard change.
- 06
Markets cost cache entries, so I collapse them. Prices only vary by currency, and sending all thirty-odd countries to @inContext would multiply the product cache for identical results — pricingCountryFor picks one representative country per currency so they share an entry. The cart still uses the visitor's real country, where shipping and tax genuinely differ.
- 07
Login is email OTP through Supabase, on HEIR's own pages, and the Shopify customer is created behind it. /api/auth/complete-signup searches Shopify by email, falls back to searching by phone when Shopify rejects a duplicate number, then retries without the phone — Shopify treats customer phone numbers as unique and the signup form should not dead-end because of it.
- 08
A user_mappings table in Supabase joins the Supabase auth user to the Shopify customer id, with partial indexes on email, phone and user id filtered to is_active = true. The Shopify webhook keeps it honest: customers/create fills in the Shopify id on a pending row, customers/update syncs name and email, and customers/delete soft-deletes rather than dropping the row, so a deletion in Shopify never silently orphans a login.
- 09
The webhook verifies Shopify's HMAC-SHA256 signature against SHOPIFY_WEBHOOK_SECRET before it parses the body, and Indian phone numbers are normalised to +91 on the way in, because the same number arrives from Shopify in several shapes.
- 10
Reviews are Judge.me, read server-side by product handle and folded into the product page's Product JSON-LD as aggregateRating, but only when there is at least one review — Google drops the whole rich result for an empty rating. ATLAS currently shows 4.8. Customers can upload up to five photos per review; those go to a Supabase storage bucket behind a login check and a rate limit.
- 11
Public endpoints sit behind Upstash Redis sliding windows, tightest on the unauthenticated user-check route, because it is the one an attacker could use to enumerate phone numbers and every attempt costs a database query. If Upstash is not configured the limiters return null and the site still runs, which keeps local development simple.
- 12
Two Vercel cron jobs: /api/reconcile at 02:00 daily counts failed events and pending or failed customer mappings so drift is visible instead of discovered by a customer, and /api/keep-alive on Mondays and Thursdays pings Supabase so the project is not paused for inactivity. The admin monitor page at /account/admin reads the same failed-events table, gated on a single ADMIN_EMAIL match.
- 13
The whole site could be locked pre-launch. Setting SITE_PASSWORD makes middleware redirect everything except the unlock route to /site-locked, so HEIR could share the real domain with photographers and the perfumer before the collection was public. Unsetting it opens the site.
- Deciding what Shopify keeps
I started by drawing the line: Shopify owns products, variants, inventory, orders, payments and blog content, and I own every pixel. That meant accepting Shopify's hosted checkout rather than rebuilding payments, which is the right trade for an Indian store — UPI, cards and the rest arrive maintained, and I do not hold card data. Everything upstream of the checkout button is mine.
- The Shopify data model
Before any UI I worked out how a non-developer would edit the pages. Long-form product content became metafields and metaobjects, the shop and family filters became a collection naming convention, and the homepage video reels became everyday_moments_video metaobjects read through the Storefront API. The test was simple: could the client change the notes on NECTRA without calling me.
- Storefront and caching
I built the Shopify client as one shopifyFetch with typed operations, then put use cache with tags and lifetimes over every read. Products and collections cache for days; the shop page is force-dynamic because it reads the country cookie and filter params per request, which makes any revalidate window a no-op there. Tag-based revalidation from the Shopify webhook is what keeps long cache lives safe.
- Accounts without Shopify's login screens
The auth flow was the slowest part, and most of the time went on failure cases rather than the happy path. Email OTP via Supabase, a Shopify customer created or matched behind it, a mapping row joining the two, and layered fallbacks for the duplicate-phone case. Session state is httpOnly cookies for the Shopify customer id plus a non-httpOnly flag so client components can tell someone is signed in without reading a protected cookie.
- Markets and multi-currency
Once the store sold outside India the single-currency assumptions had to go. Geo detection in middleware, a manual switcher that overrides it, the country list pulled from Shopify Markets rather than a constant in the code, and the per-currency cache collapse so thirty markets do not become thirty copies of the same product.
- Reviews, SEO and structured data
Judge.me went in server-side so ratings are in the HTML rather than loaded by a widget. Product pages emit Product and BreadcrumbList JSON-LD with shipping and return details, prefer the SEO title and description merchandisers set in Shopify, trim descriptions to a clean 160 characters, and carry up to four product images for link previews. The sitemap is generated from live Shopify products, collections, pages and articles.
- Launch and handover
The site ran behind SITE_PASSWORD until the collection was ready. I wrote the README as a handover document — which service does what, which ones stop the site if billing lapses, what is done in the Shopify admin and what needs a developer, and what to rotate before the keys change hands. Ownership of the domain, Shopify store, Vercel project and Supabase project transfers to HEIR, not to me.

The first collection: five 50ml fragrances at ₹4,500 and a ₹1,200 discovery set

The ATLAS product page, with its size options, accord chips and Judge.me rating

The About HEIR section on the home page

The about page, built from the brand's own photography

The journal, pulled straight from Shopify's blog
On a phone, where most of the traffic is


HEIR launched its first collection on heirbeauty.com: six products, five fragrances at ₹4,500 for 50ml — ATLAS, NECTRA, LANÖ, MĪRĀS, KEDARA — and a ₹1,200 discovery set for people who want to try before they commit. ATLAS carries a 4.8 rating from Judge.me reviews, which appear in the page HTML and in its structured data. Day to day the client works entirely in Shopify: products, prices, photos, stock, orders and blog posts, with the storefront following through tag-based revalidation. The things that used to need a developer — a new olfactive family, a new market and currency, the notes on a product page — are now dashboard edits. The nightly reconcile and the admin failed-events page mean a broken customer mapping shows up on a screen rather than in a support email.
Scope
Headless Shopify storefront, Shopify data modelling (metafields, metaobjects, collections), Custom auth and customer accounts, Multi-market pricing and currency, Reviews and review image uploads, Technical SEO and structured data, Caching and revalidation, Cron jobs and admin monitoring, Handover documentation
Stack
Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS v4, Shopify Storefront API, Shopify Admin API, Supabase (auth, Postgres, storage), Zustand, Framer Motion, Lenis, Embla Carousel, Radix UI, Judge.me, Upstash Redis, Vercel (hosting, cron, analytics)
Want results like this? Tell me about your project.
- Projects from $500
- Reply within one business day
- Free, no obligation
Next case study
Ishikaa Jain couture store