Work/Launched · Full-Stack Platform

Alexander's Antiques — A Gallery That Sells by Conversation

Alexander's Antiques is a gallery in Manhattan whose pieces used to live on 1stDibs. It wanted its own house: a storefront where a collector can decide from across an ocean, an inquiry instead of a checkout, and one workbench where the staff answer. I built all three — storefront, admin, and API — and it is live at alexanderantiques.com.

Role
Lead Full-Stack Engineer · Freelance
Timeline
4 months · Jul–Nov 2026
Team
3 contributors · I authored 89% of commits
Year
2026
The live catalogue. The pointer steps through a tile's photographs, opens the blue opaline lustres, and turns the inquiry into a $4,000 offer.
939
pieces live on the site
~130K
LOC across web, admin, and API
4,300+
automated tests in one gate
83/83
port methods proven at cutover

01 — Problem

A gallery, not a marketplace

On 1stDibs the gallery's pieces sat beside everyone else's. The house wanted its own address: every piece its own inventory, a real gallery on Second Avenue, and a sale that ends in a conversation with the people who own the piece.

That rules out a cart. A collector in London looking at a $19,850 cloisonné elephant wants a shipping quote, a condition report, maybe a video call — not a checkout button. The product works when a collector reaches a confident inquiry about the right piece, and the staff can answer it from one place.

02 — The piece

Decidable from a distance

The piece page carries what a remote buyer needs to judge it: the photographs, the period, the maker and whether the piece is by or attributed to them, the measurements as named dimensions, and an obvious next question. One button opens it.

Photographs load because someone asked for them: the viewport arrived, a pointer settled, a thumbnail was pressed. Measured on the live site, a piece with 19 photographs fetches 9 images before the first click.

Large Chinese cloisonné enamel incense burner modeled as a caparisoned elephant on its piece page, $19,850, with the inquiry action
A cloisonné elephant, $19,850. The inquiry sits under the price. The thumbnails wait for a click.

03 — Inquiry

Six questions instead of a checkout

The dialog offers six ways in: purchase, make an offer, request information, a condition report, a shipping quote, or a viewing — in Manhattan or by video. Each carries its own fields: an offer in cents, a destination and a postal code, in person or on a call.

A verified phone is the price of entry. Collectors sign in without a password by a code to that phone, Turnstile guards the send, and the SMS consent is stored with the version of the words they agreed to. The opening submission becomes the first card of a thread, not a message.

Admin conversation for a shipping-quote inquiry to London, with the gallery's reply and the collector's answer
A shipping quote to SW3. The submission is the first card. The house answers with a price and the finial. The collector answers back.

04 — Workbench

Answer from one place

Staff work a queue: new, responded, closed, closed by the collector. Opening a thread records who opened it first. A reply moves it to responded and sends the collector a letter, logged on the same page with its trace. A collector writing into a closed thread reopens it.

Every status write is a guarded update, so a collector writing while staff close the thread resolves to one stored state, never two.

A video viewing for the Caldwell lamps. The reply goes out, the badge turns to Responded, and the letter is logged with its trace.

05 — Architecture

One seam, proven by count

The storefront and the admin shipped first, on fixtures, behind repository ports. When the NestJS API arrived, no component changed: an HTTP adapter implemented the same 83 methods across 16 ports, parsed every response with the same Zod schemas, and mapped the API's errors back to the codes the fixtures already threw.

A parity suite asserted every method by name, so cutover was a count reaching 83, not a feeling. dependency-cruiser holds the boundaries — the API may import the domain and nothing else — and OpenAPI is generated from the domain schemas, committed, and diffed in CI, so a breaking route fails the build instead of the launch.

Diagram of the storefront and admin calling 16 ports in packages/data, with mock and HTTP adapters, the NestJS API, and the shared domain package
Two apps, one door to data. The mock and the HTTP adapter answer the same 83 methods. The domain is the only definition.

06 — Migration

917 pieces, and nothing invented

The catalogue came from the gallery's 1stDibs listings: 917 pieces across a four-level taxonomy of 124 categories. The model followed the data, not the other way round. 294 pieces have a diameter and five have no measurement at all, so dimensions became a named map instead of height, width, and depth. A source that says Unknown stores nothing.

The import is an operator command, never a seed. It reports first, refuses to write when its counts disagree with the signed figures, is safe to run twice, and touches catalogue rows only — never an account, a session, or a setting. A piece publishes with exactly the facts its listing states.

Admin products list with 917 pieces, prices, leaf categories, and published status
917 total, 37 pages. Every row is a piece the import placed in a leaf category, at the price its listing stated.

07 — Phone

Two columns, and an inquiry that stays

On a phone the collection is two columns, the header compacts as you scroll, and the inquiry action stays on screen while the photographs move under it.

The storefront also lost weight while it gained motion. The three animations that pulled a JavaScript runtime into the first load became CSS, and home's first-load script fell from 558 KiB to 476 KiB.

Asian Art on a phone. Two columns, a tap, the famille rose pair — and the yellow bar stays put while the photographs scroll.

08 — Quality

A gate, not a review

Typecheck, lint, 4,300+ tests, the build, and the dependency boundaries run in that order on every change. The domain package holds a 90% coverage floor and everything else 80%. The storefront targets WCAG 2.2 AA and holds a Lighthouse accessibility score of 100 in CI.

Work moved through 27 written specifications — specify, plan, tasks, implement — and shipped UI changed only under an exception signed in the roadmap. That discipline is how three people shipped this in four months without the codebase fighting back.

09 — Outcome

Live, under the house's own name

alexanderantiques.com is live: 939 pieces in seven departments, a collector's portal with a wishlist and threads, the Collector's Letter with confirm-to-subscribe, and a workbench where staff publish, merchandise the home page, invite colleagues, and answer.

Nothing on the site claims more than the house can stand behind. There is no provenance field, because there is no document to back one. That was a product decision, and the schema enforces it.

Stack

Next.js 16React 19TypeScriptNestJSPostgreSQLDrizzleBetter AuthZodTanStack RouterTanStack QueryTurborepoTailwind CSSCloudflare R2TwilioResendOpenTelemetrySentryDocker

Next case

Smart School — One App, Two Desks→