DEV Community

Mohamed DJOUDIR
Mohamed DJOUDIR

Posted on

I built a full-stack Next.js 16 e-commerce monorepo: storefront, admin, NestJS API, and AI try-on

I shipped a production e-commerce system as a single monorepo: a Next.js 16 storefront, a Next.js 16 admin dashboard, and a NestJS API. English and Arabic with full RTL, guest checkout, and three AI features that live inside the codebase instead of being bolted on top.

This is the build log. The decisions I would defend, the ones that cost me a week, and what I would do differently.

Three apps, not one

The obvious move is one Next.js app with /admin routes behind a guard. I split it into three.

The storefront and the admin have almost nothing in common at the boundary. The storefront is public, SEO-critical, and cached aggressively. The admin is authenticated on every request, data-heavy, and never indexed. Sharing a build means the storefront pays for Recharts, the data grids, and the admin's client bundle even when nobody signs in.

Splitting also made auth honest. Customers and staff are different tables with different token payloads and different refresh lifetimes. When they share an app you inevitably end up with one User model carrying an isAdmin boolean, and RBAC on top of a boolean is how privilege escalation bugs happen.

Cost of the split: three ports in dev, a shared types package, and CORS config you have to actually think about. Worth it.

RTL is a layout problem, not a translation problem

Arabic support is where most templates quietly fail. Translating strings is the easy half. The hard half is that every hardcoded margin-left, left-0, flex-row, and chevron icon is now wrong.

The rule that fixed it: no physical direction properties anywhere. Logical properties only.

/* breaks in RTL */
.card { margin-left: 1rem; padding-right: 2rem; }

/* works in both */
.card { margin-inline-start: 1rem; padding-inline-end: 2rem; }
Enter fullscreen mode Exit fullscreen mode

Tailwind 4 makes this workable because ms-*, me-*, ps-*, pe-*, start-*, and end-* are first class. Once I banned ml-, mr-, pl-, pr-, left-, and right- from the codebase, the same components served both directions with no mirrored variants.

The leftovers that still bite:

  • Icons with implied direction. A "next" chevron has to flip. A logo does not. There is no automatic rule, you tag them by hand.
  • Carousels. Embla needs its direction option set from the locale or drag goes the wrong way.
  • Numbers and currency. Intl.NumberFormat with the right locale, not string concatenation.
  • Transforms. translateX does not mirror. Animations needed direction-aware values.

For messages I used next-intl with one file per feature per locale rather than one giant en.json. A 2000-line translation file is unreviewable and guarantees merge conflicts. checkout.en.json next to checkout.ar.json means a translator touches one file and you can diff it.

Guest checkout: the order is the identity

Forcing account creation before purchase kills conversion, so guest checkout was non-negotiable. It changes the data model more than you would expect.

If order.userId is non-nullable, every guest order needs a phantom user, and now you have a users table full of ghosts that break your customer analytics. Instead the order owns its own contact and shipping snapshot, and userId is nullable.

Snapshot is the key word. The shipping address on an order is a copy, not a foreign key to an address row. When a customer edits their saved address six months later, historical orders must not silently change. Same for line items: product name, variant, and unit price get denormalized onto the order line at purchase time. Prices change. Invoices should not.

Guest order tracking is then just order number plus the email used at checkout. No account, no magic link infrastructure, and the lookup is rate-limited so it is not an enumeration oracle.

Virtual try-on

A shopper taps a hanger icon on any product page and sees the garment worn, either on a built-in model or on their own uploaded photo.

The architecture that made it feel fast:

  1. The upload goes straight to S3-compatible storage with a presigned URL. The API never proxies the image bytes.
  2. Generation is a job, not a request. The client gets a job id immediately and subscribes over Socket.io.
  3. Results are cached per garment plus model pair, so the same product on the built-in model is generated once, ever. That is most of the traffic.
  4. Per-account quotas, enforced server-side. Image generation has real unit cost, and an unmetered endpoint is a bill waiting to happen.

The piece I underestimated: composition. Shoppers do not want one item, they want an outfit. Building up a look piece by piece meant persisting the try-on session state so the next garment composites onto the previous result instead of starting from a blank model.

The AI studio lives inside the product editor

Merchants do not want a separate AI tool. They want the product form to fill itself.

So the generation UI sits in the product editor: generate product imagery from a prompt, remove backgrounds, draft the listing copy from a single uploaded photo, and turn a still into a short product video with motion presets.

The detail that changed usage more than the model choice: three one-tap prompt suggestions sitting directly above the composer. "Write the product details." "Clean white studio shot." "Photograph this on a model." Tap, then Send. A blank prompt box gets ignored. Three buttons get pressed.

The admin assistant queries the database, not the internet

The dashboard assistant is a streaming chat, but the interesting part is that it answers from store data through an MCP tool registry rather than from general knowledge.

Each tool is a typed, scoped function: revenue for a period, orders awaiting fulfilment, top sellers, low stock. The model picks tools and composes the answer. It cannot free-form SQL, so it cannot leak or wreck anything the tool contract does not allow.

Two things fell out of this design:

  • Provider choice is trivial. Anthropic, Google, and OpenAI all speak tools, so the provider is config. Bring your own key.
  • Answers are auditable. Every claim traces to a tool call with real numbers, not a plausible hallucinated figure.

Suggested questions on the empty state again, for the same reason as the studio prompts.

Real-time without a state mess

New orders, status changes, and notifications push over Socket.io. The temptation is to write incoming events into a client store and treat that store as the source of truth. That path ends in two copies of your data that disagree.

What worked: the socket event carries an identifier, not a payload. The handler invalidates the relevant TanStack Query key and the normal fetch path refills it. One source of truth, no reconciliation logic, and a page that recovers correctly after a reconnect.

Redux Toolkit stays for genuinely client-owned state: cart, wishlist, UI preferences. Server state never enters it.

Boring things that mattered most

  • A one-command seeder. Products, variants, images, customers, orders across every status. Nothing kills first-run enthusiasm like an empty database and a broken chart.
  • Exact pinned dependencies with committed lockfiles. No carets. A template that installs a different tree next month is a support ticket generator.
  • Branding in one config file per app. Colors, logo, store name. If a rebrand means grepping for hex codes, the theming is fake.
  • No flash of the wrong theme. A blocking inline script reads the stored preference before paint. Tiny detail, immediately noticeable when missing.

What I would do differently

I would design the RTL rule on day one instead of retrofitting it. Auditing every physical property after the fact took a week of tedious work that a lint rule would have prevented from the start.

I would also make the AI features quota-gated from the first commit rather than adding limits later. Retrofitting metering into an existing generation flow is more invasive than it sounds.

Try it

The whole thing is available as a template if you would rather start from a working store than a blank create-next-app.

Stack: Next.js 16 App Router, React 19, TypeScript 5, Tailwind CSS 4, TanStack Query, Redux Toolkit, React Hook Form and Zod, next-intl, NestJS, TypeORM with MySQL or SQLite, Socket.io.

Happy to go deeper on any part of this in the comments. The RTL and the try-on caching are the two I have the most notes on.

Top comments (0)