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.
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.
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 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:
- The settings page silently destroyed work.
/dashboard/settingshad 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. - Every
/dashboard/*route returned 500 to anyone not logged in. Root cause: a Server Componentredirect()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. - The onboarding checklist was hardcoded. "Install the widget snippet" was
complete: trueas a literal. "Test your live widget" wascomplete: 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.
widget_opened events instead of the hardcoded ticks it used to show. It says "no shopper activity yet" because there genuinely isn't any.
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.
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.