Case study 01 / For hiring managers and technical recruiters

ModlyAI

Sole engineer, designer, and founder · Jan–Aug 2026 · modlyai.tech
Every line, every design decision, every deploy. No teammates, no reviewers, no QA.

An embeddable widget that places a shopper's furniture pick into their own room photo at the correct scale, plus the merchant dashboard behind it.

The problem

Furniture is one of the worst things to sell online. A shopper can't tell whether a sectional clears their living room, and the retailer eats the return. On made-to-order and custom-upholstery pieces it's worse than a return: there's no restock, and lead times of four to twelve weeks mean a wrong guess is months gone.

The qualifying signal came out of prospect research: retailers who'd already written a "how to measure your space" guide, or mailed out physical fabric swatch kits, or sold a paid virtual design consultation. Those retailers had already told the world this problem was costing them money, and were paying humans to solve it one shopper at a time.

ModlyAI is a widget a shopper uses on the product page: upload a photo of your own room, see the piece placed in it at the correct scale, change the fabric or finish, send a quote request, all without leaving the page. Sold to independent furniture retailers at $299 and $599 a month.

The ModlyAI widget embedded on a demo product page for a walnut dining set. The shopper asked whether it fits a 3.5 m dining room; the assistant answers with the piece's real dimensions (96 by 42 inches, about 2.4 by 1.1 m), says it fits with roughly 1.1 m of walking space around it, and shows a catalog product card underneath with an 80% match and a Customize button.
The live demo, answering a fit question from the fictional catalog on the page. This is the whole pitch in one screen: a shopper's own measurement question, answered on the product page with the real spec, before they leave.
The widget's Room Planner tab with a sample dining room photo loaded into the Room preview panel, labelled 'Photo loaded'. No products have been placed yet.
Room Planner: the shopper's room photo, loaded.
Three catalog matches returned for the room, each with a match percentage, price and real dimensions in metres. Each card carries a Placement notes block: a position in metres from the southwest corner, distance to each of the four walls, and a location like 'Against north wall', with a sentence explaining the fit.
The same room, matched against the catalog: each suggestion comes back with a position in metres, clearances to all four walls, and why it fits. Correct-scale placement is the hard part of the product, so the output is spatial, not a carousel.

What I built

The widget opens as a chat: a shopper asks a fit question and gets an answer grounded in the store's own catalog, not a generic reply. From there, the Room Planner tab takes an uploaded room photo, reads back style, color, and layout cues from the image itself, and returns catalog matches with a placement position and clearances to all four walls, not just a carousel. The Customizer tab sets color and material on a made-to-order piece — plus a free-text field for anything else the shopper wants — every option priced from the merchant's own catalog data, before it becomes a quote request to the retailer with the exact spec attached.

On the merchant side, there's a dashboard: an onboarding checklist that includes uploading the product catalog and installing the widget snippet, an overview with sync and usage stats, widget branding settings with a live preview, and analytics with date-range filtering.

The stack, and why

Next.js 14 App Router, TypeScript 5.5, React 18: Server Components keep merchant data server-side, one framework covers marketing site, dashboard, and API. InstantDB: chosen for iteration speed as a solo dev, the honest retrospective on that is below. NextAuth 4, JWT strategy: sessions readable in Edge middleware with no database round trip on every request. Paddle: merchant of record, handles VAT and sales tax across the US, UK, and EU, which a solo founder can't do alone. Tailwind 3: consistency without maintaining a design system by hand. Vercel: main auto-deploys, fast, and also why a bad merge is live in ninety seconds. Rollup for the widget: merchants get one script tag, the widget can't assume React exists on their storefront.

121commits, Jan–Aug 2026
46,710lines of TS/TSX
61API routes
52React components
660 KB → 144 KBwidget build, raw then brotli'd

That 660 KB is the raw bundle; Vercel serves it Brotli-compressed at 144 KB over the wire, and a stronger Brotli pass gets it to 114 KB (gzip -9 lands at 141 KB). It ships no-cache today, so a storefront re-fetches it on every page view. On a host page the widget's launcher button paints in about 1.1 s, almost all of it downloading and parsing that bundle from the CDN, with the config call and React mount another 50-100 ms on top.

The widget's Customizer tab: a product selector, colour options (Walnut included, Black +$60, Cream +$45), material options (Solid Wood included, Bouclé +$180, Brass +$250), and a free-text box for describing what the shopper wants, with quick-add chips like 'Pet-friendly fabric' and 'Matches grey walls'.
The Customizer, where a made-to-order piece gets its finish and size set before the quote request goes to the retailer with the exact spec attached. Every option is priced from the merchant's own catalog data.

The InstantDB decision, told honestly

InstantDB was picked for iteration speed. In August 2026 the InstantDB team announced they were joining OpenAI: signups closed, cloud apps shut down 31 August 2027, backups retained to August 2028. Rather than panic-migrate, I measured the actual coupling: 52 files use the server-side admin SDK, 78 queries and 41 transactions; 2 files use the realtime React SDK, with exactly 2 live subscriptions (a notification badge and a trial banner); the embeddable widget has no direct dependency at all, it goes through the API.

So the product was using a realtime sync database as a plain server-side store. The expensive part of leaving a sync database, reimplementing optimistic updates and subscriptions on the client, didn't apply. What remained was a mechanical rewrite of server-side query call sites. Self-hosting also exists as a zero-code-change fallback, which caps the downside. Decision: don't migrate now, migrate at the third paying merchant or February 2027, whichever comes first, because the cost curve tracks number of live merchants, not the calendar.

The hardest part

Working alone, I had no mechanism for finding out I was wrong. No reviewer, no QA, no users yet. The only feedback available was the compiler, and the compiler was structurally incapable of catching anything that actually mattered. To this day the repo has no test suite at all, zero unit tests, zero end-to-end, no test runner even installed; the verification practice below is what took its place.

A code review of the dashboard surfaced three things at once, all live in production for months:

  1. The settings page silently destroyed work. /dashboard/settings had three save buttons with invisible scope. A merchant could edit their widget title, scroll up, click the button labeled "Save," and get back "Saved." The widget title was not saved. No error. The change was gone on next load. The product displayed a success message while throwing the user's work away.
  2. Every /dashboard/* route returned 500 to anyone not logged in. Root cause: a Server Component redirect() thrown across a 'use client' boundary, with no error boundary anywhere in the tree. Every prospect who clicked a link in a cold email without an active session hit a crash page.
  3. The onboarding checklist was hardcoded. "Install the widget snippet" was complete: true as a literal. "Test your live widget" was complete: false. Neither checked anything. The first thing a new merchant saw was a lie about their own account.

There were others: analytics computed every number over the store's entire event history while offering a filter that said "All time" versus "30 days," and silently showed 30 either way. The mobile navigation drawer was invisible because the <header> carried backdrop-blur, and a non-none backdrop-filter makes an element a containing block for position: fixed descendants, so the drawer sized itself to the header's 90px box instead of the viewport.

The line that's the whole point: every one of those bugs passed tsc --noEmit. Every one shipped through a green build. Type safety proved the code compiled. It could not prove a drawer opened, a save persisted, or a checklist told the truth. Seven months of "the build is green" had been measuring the wrong thing, and I'd been cold-emailing furniture retailers the entire time, driving them to a dashboard that crashed if they weren't logged in.

The smaller story that proves the fix, and it's the whole lesson in miniature: doing an accessibility pass, I computed WCAG contrast ratios with hex math against an assumed white background and prescribed six fixes. Re-measured by sampling actually rendered pixels, three of the six still failed. The app's surfaces are a warm cream and a blue-tinted white, not #FFFFFF. The math had been correct and the answer had been wrong, because the input was an assumption rather than a measurement.

What actually changed wasn't "I fixed the bugs," the bugs were symptoms. It was the verification practice: CLAUDE.md, working notes that open with a Traps section, every trap that cost real debugging time written down with its mechanism. DASHBOARD-CHANGELOG.md, a record of what changed and a Still open list that names unsolved problems instead of hiding them, including a revoked-session crash still not fully diagnosed. A standing convention: "Verify by using it, not by building it. Type-checking proves it compiles, not that a drawer opens or a save persists." "Say what you checked and how," claims about behavior cite the line that was read, anything unverified gets named as unverified. And destructive verification gets confirmed first, or runs against records it created itself, because bulk-delete testing once permanently removed five real products.

The result

Shipped and live. modlyai.tech is in production on Vercel. The widget installs as a single script tag on Shopify, WooCommerce, or a CSV catalog.

The merchant dashboard Overview for the demo store: header 'No shopper activity yet this week', the widget marked 'Installed', an onboarding checklist reading '2 of 4 complete' with 'Upload products' and 'Install the widget snippet' ticked from real state, and stat tiles showing 39 products synced, 50 AI sessions, 26.0% conversion rate.
The dashboard on the demo account: real counts, and an onboarding checklist that now reflects actual widget_opened events instead of the hardcoded ticks it used to show. It says "no shopper activity yet" because there genuinely isn't any.
The Room Planner's analysis panel after processing the sample room: a green 'Success' badge, 'Observed style cues: Modern', 'Color cues: Walnut, Beige, Cream', and 'Layout notes: Open area around the dining table and near the windows', above the start of the matching catalog products list.
What the demo reads back from a room before it recommends anything: style, colour and open-floor cues, all traced to the uploaded photo and the store's catalog rather than invented.

Engineering outcomes, all verifiable: settings split into four tabs with one save scope each, deep-linkable via ?tab=, per-tab dirty tracking, beforeunload guards, the silent data loss path no longer exists. Six per-page auth guards replaced with a single src/middleware.ts using withAuth at the edge, the 500 on logged-out routes is gone. Onboarding checklist derives from real widget_opened events. Analytics scoped to a real date range. A full accessibility pass: skip link, focus traps, aria-current, dialog semantics, 44px touch targets, contrast measured from rendered pixels. Every overlay portals to document.body with an SSR-safe mount guard.

The rebuilt settings page: four tabs across the top (Widget, Store, Account, Security), the Widget Branding form below with a live preview of the chat widget, and a single 'Save widget settings' button in the bottom-right whose label names exactly what it saves.
The settings page that used to have three save buttons of invisible scope, now four tabs with one save each. The button says what it saves; the tab you are on is the scope. The path that showed "Saved" and discarded the change is gone.

Performance, measured against production: the demo catalog route — the live demo's data source — answers in 434 ms p50 / 508 ms p95 over 60 requests; the widget-config route, hit with an unknown store so it runs the full handler and the InstantDB lookup but returns not-found, comes back in 456 / 527 ms. Both are over the public internet, so network is part of the figure. Lighthouse 12.8.2, run locally against the live site, scores desktop performance 74, held there by a largest-contentful-paint of about 5.5 s — the metric to bring down.

Commercially, honestly: zero customers. Around 35 furniture retailers contacted by cold email, each on a three-touch sequence, plus a live demo running on a public URL. One inbound conversation from a manufacturer in Nagpur. No signed pilot yet. That gap is the real state of it, and pretending otherwise would undercut everything above.

What I learned

The compiler was the only reviewer I had, and it couldn't see any of the things that were actually wrong. What I built in the second half of this project wasn't features, it was a way to find out I was wrong before a merchant did.