Overview

Zodega

An open-source Next.js starter for launching an AI-powered apparel marketplace. It includes a shopper storefront, seller portal, admin portal, multilingual routing, R2 media storage, Postgres data access, authentication, and optional AI flows for product discovery, reference-photo try-on, and generated product media.

TypeScriptReactNext.jsPostgreSQLDrizzle
Vercel AI SDKCloudflare R2next-intl
Zodega

Zodega didn’t start life as a starter. It’s the generalized, open-source version of a real apparel store I built, launched, and operated, with real sellers, real shoppers, and real orders. Most commerce starters on GitHub have never processed a purchase; this one earned its design decisions in production first and became a template second. I’ve kept the original store unnamed here; what’s worth talking about is the engineering, and there’s one fact about it that everything else on this page hangs off: the whole thing is a single Next.js app. Storefront, seller portal, admin dashboard, and every AI feature all live in one codebase, one router, one runtime. That sounds like an implementation detail. It’s the reason the AI on this store can do things bolted-on e-commerce integrations simply can’t. So let me show you that first, and then walk down the stack to the decisions underneath it.

A voice shopping assistant that actually drives the app

This is the feature I’m proudest of. There’s a single button in the storefront that drops you into a live conversation with a real-time voice model, using WebRTC in the browser over OpenAI’s Realtime API, and it isn’t a chatbot pasted into a corner. It has tool calling and generative UI wired in; it knows what page you’re currently on, and it can operate the store on your behalf.

That last part is the payoff of the single-app decision. Because the storefront is just Next.js routes, the assistant has access to where the user actually is and can navigate them directly. You can say “show me yellow dresses that are on sale,” and it hits a search tool that cuts across the entire catalog with granular filters and hands back ten to twenty products as real UI, not a text list. You keep refining until you’re happy. Then you say “okay, show me that one,” and instead of telling you where to click, it navigates you straight to the product page. It sees the live cart too, so “add it and check me out” ends with the assistant opening the checkout page. The user is only ever talking; the assistant is doing the clicking. AI integrations that sit outside the app can only ever describe. This one is the app, so it can act.

It also remembers. There’s a deliberately simple memory tool: the assistant saves a fact when one’s worth saving, and for a logged-in user those memories get injected into future turns automatically, across every thread in the app. Tell it once that you only wear cotton, and next month’s conversation already knows.

And the virtual try-on lives right inside the same conversation. You save a photo of yourself once, and from then on any product on the site can be tried on against that reference with an AI image model, in real time. A couple of seconds later, you’re looking at yourself in the item. It’s the kind of thing that only feels magical when it’s in the flow: you’re talking to the assistant; it’s pulled up a dress, and you just see it on.

Why it feels smart: append, never rewrite

My other projects are mostly systems work: runtimes, permission models, delivery guarantees. This is where the same rigor goes up the stack: understanding how models actually attend, what they should be shown at each moment, and what that does to the experience of the person talking. The context engineering here took more iteration than any other part of the assistant, and it all obeys one rule: never invalidate the cache. Rewriting an earlier message or mutating the system prompt mid-conversation throws away the provider’s prompt cache and re-bills every token above the change; appending is dramatically cheaper and faster. Conveniently, models pay the most attention to the end of a long prompt anyway. So the system prompt is built only from stable data (store identity, locale, tool instructions, the user’s memories) and stays byte-identical across turns, while everything dynamic arrives as appended context.

Page awareness is the clearest example. The storefront preloads the current product’s metadata through fast server actions (name, brand, category, price, available sizes and colors, stock) and attaches it as a [PRODUCT CONTEXT] block on your latest message, never into the system prompt. Browse to another product mid-conversation and a new block gets appended, one at a time, with a standing instruction that the latest block is the current product and earlier ones are history. Supersession happens by instruction instead of by rewriting; old context ages out of relevance without a single cached token being touched. The persisted conversation stays clean too: the database stores what you actually said, not synthetic messages inflated with page state.

The payoff is the first ten seconds of every chat. Open the assistant on a product page and it already knows what you’re looking at, so instead of “how can I help you today?”, it asks about your size, mentions the colors in stock, or pulls up related items. That’s the difference between an assistant that feels bolted on and one that feels like it was standing next to you the whole time, and it’s downstream of an unglamorous caching decision.

The prompt itself is engineered with the same specificity. The memory instructions don’t say “save useful information.” They enumerate exactly what to capture (fit, sizes, brands, budget, occasions, gifting constraints) and include a literal WRONG/RIGHT example pair, because the failure mode of memory tools is the model saying “I’ll remember that” without calling anything. Tuned this way, the assistant silently files away nearly everything you mention about yourself. The search-tool rules teach filter discipline the same way: start broad, don’t guess gender or budget from vibes, never send the redundant {query: "dress", category: "Dresses"}, and if a multi-filter search comes back empty, retry dropping the least-certain filter first. And the JSON the tools return went through just as many revisions as the prose: shapes tightened until the model reliably made good recommendation decisions from them instead of drowning in fields. A small tell that this was operated, not just built: product results handed to the model are stripped to minimal fields, no rich display data the client can derive itself. You only start trimming payloads like that after you’ve watched an AI UI burn context on fields it never needed.

Two models, two tool designs, on purpose

The voice assistant and the text assistant are different models with different strengths, so I designed their tools separately rather than pretending one interface fits both. The voice model is optimized to feel live: real-time, in the browser, calling tools and searching the catalog fast enough to keep a conversation flowing. But real-time models trade intelligence for latency, and I designed around that honestly instead of pretending otherwise: the voice model’s tools are simpler, and the code prunes its context and curates its results programmatically, pre-digesting what a smarter model would be trusted to sift itself. The text model is longer-context and more capable, so its tools expose more filters and lean on it holding more of the catalog and conversation in its head at once.

Voice mode has a structural difference worth naming too: the browser is the tool runtime. Tool calls arrive over the WebRTC data channel, the client executes the same server actions directly, and the result gets posted back over the channel. There’s no HTTP round-trip through a chat endpoint in the middle of a live audio exchange. Tools return error objects instead of throwing, so a failed cart action is something the model reads and recovers from mid-conversation rather than a dead turn. The voice prompt even carries its own etiquette layer: internal product IDs are passed for silent tool use with explicit instructions to never say them aloud.

The agents call exactly what the buttons call

The load-bearing decision underneath all of it: the entire app runs on one Postgres database through a set of well-shaped server actions, and the AI tools are those same server actions wrapped with the AI SDK. Search, cart mutations, navigation: the agents call exactly what the buttons call, so there’s no second, drifting copy of the commerce logic for the AI to get subtly wrong. Conversations, messages, tool calls, tool results, per-user memories, and rate limits are all first-class tables, not ephemeral state. The rate-limit increments are atomic upserts keyed on user and date, because a read-then-write counter is exactly the kind of race a concurrent web app will eventually lose.

The catalog model is what makes the AI search good, and it’s a decision in its own right. Apparel catalogs usually die by category tree, such as Girls > Toddler > Pink Dresses, which can’t scale and is miserable for AI search. Zodega’s taxonomy keeps the category tree for what an item is (a dress, a jacket) and moves everything about who it’s for into orthogonal facets: gender, age group, color, size, plus an attribute-definition layer for category-specific properties later. Filters compose instead of multiplying into branches. On top of that sits a real translation layer rather than a passthrough: fuzzy color aliases, category resolution that includes children, human phrases like “for a five-year-old” mapped to age bands. The model speaks shopper; the query speaks schema; that layer is the interpreter between them.

The marketplace economics are in the schema, not bolted on later

None of the AI matters if the marketplace underneath it is mush, and this is where operating a real store shaped the code most. Pricing is modeled as a marketplace from the start, not as a single price column with a commission calculation hoping to reconstruct margins later. A seller states what they want to receive (supplierPrice); the admin approves the product and sets the final on-site price (salePrice) based on whatever commission agreement they have with that seller; there’s a separate listPrice for the crossed-out psychological anchor. Variants can override both. None of that is inferred; it’s distinct fields with distinct owners.

The part I care about more is what happens at checkout. Each order item snapshots what the customer paid per unit, what the seller receives, and the platform profit, all computed and frozen at purchase time. Freezing the economics into the order row means a later product edit, a seller changing their cost, or a promotion expiring can’t silently rewrite historical margin. Order history stops depending on mutable product and pricing rows. It’s the same instinct behind snapshotting shipping details and product labels at the boundary: the order is a record of what happened, not a live join against tables that keep moving.

The seller/admin split is a workflow, not just a permission check

This falls straight out of how a real multi-vendor store actually operates. Sellers should be able to say what they want for their goods; the store operator should own the final price the shopper sees and the commission spread in between. So the data model itself encodes who’s allowed to make which decision, not just which pages each role can open. A seller creates a product with salePrice: null. They can state facts and their desired cost but literally cannot set a customer-facing price. Admin approval is what supplies a valid sale price, and a seller later editing their own product doesn’t overwrite the admin-managed pricing. The same gate applies for the life of the listing: a seller’s edits to a live product are proposed changes that sit in an approval queue, and nothing reaches shoppers until the admin signs off. Admin-created “house” catalog goes through an internal seller identity (MARKETPLACE_INTERNAL_SUPPLIER_ID) so the exact same flow works whether the goods are third-party or first-party.

One detail in the admin product update path: before deleting a product variant, it checks whether that variant is referenced by anything in a cart, and only deletes the ones that are safe. An admin tidying up variants shouldn’t be able to blow up a shopper’s cart mid-session. That’s the kind of guard you only write after you’ve watched real state get corrupted by an “obviously safe” cleanup.

AI in the admin dashboard, natively

I’ve built isolated AI image tools before: Product Pics for batch apparel catalog shots, AgentStudio for a multi-model generation studio. Zodega is where that capability stops being a separate tab you export from and becomes native to the operator’s workflow. Admins generate and edit product imagery with AI image models directly in the dashboard, against the actual catalog. The same native-integration idea runs through the rest of the back office: AI meta generation for SEO, and English↔Arabic translation for product content, both a click away where the operator is already working instead of in some other tool. And there are full workflows for merchandising campaigns: discounts, plus banner images and videos generated in place. The store operator never leaves the store to produce the media the store runs on.

The assistant itself is observable from the same dashboard: full analytics on conversations, tool calls, and usage, because an AI feature you can’t inspect in production is an AI feature you can’t improve.

The rebrand is compatibility-first, and I left the seams visible

Turning a branded, live store into a neutral starter is where systems usually break, because the coupling is invisible: database enums, route paths, stored R2 media keys, AI prompt assumptions, and docs all quietly depend on the old names. The tempting move, a sweeping rename in one release, is exactly the move that corrupts stored data, so I didn’t make it. Instead there’s a runtime compatibility layer that keeps the storage stable and neutralizes only the public surface. store-config.ts sanitizes legacy branding on read. If a store’s saved name still matches the old brand (matched against a set of patterns, including the Arabic spelling), it falls back to a neutral default instead of ever rendering it. The schema keeps compatibility values like the supplier role, the kid_photos table, and a shipped_to_kidsway order-status enum, all documented in the README as storage-compatibility artifacts rather than pretending the history never happened. New public surfaces get neutral names; old stored values get shims. It’s less glamorous than a clean rename and more honest. The README literally tells a downstream adopter which names are load-bearing storage and which are safe to change. I’d rather ship that than a repo that looks pristine and quietly drops someone’s order history on first migration.

The stack

Next.js 16, React 19, Bun, Drizzle over Neon Postgres, better-auth, next-intl for en/ar/es (Arabic RTL), Cloudflare R2 for media, and an AI layer split across the Vercel AI SDK with DeepInfra for text chat, OpenAI Realtime for voice, and RunPod / Runware for image and video generation. Every provider past the database/auth/R2 baseline is optional and degrades only its own feature. A missing generation key disables try-on or campaign media; it doesn’t take down the store. That “optional providers are truly optional” stance is deliberate: the baseline storefront has to deploy with nothing but a database, an auth secret, and a bucket.

Use and to navigate