cd /news/developer-tools/i-built-a-full-stack-next-js-16-e-co… · home topics developer-tools article
[ARTICLE · art-97863] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer shipped a production e-commerce monorepo with a Next.js 16 storefront, admin dashboard, and NestJS API, featuring English/Arabic RTL support, guest checkout, and three AI features including an AI try-on. The build log details decisions on splitting the app, using logical CSS properties for RTL, and denormalizing order data for guest checkout.

read5 min views1 publishedAug 15, 2026

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.

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.

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; }

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:

Intl.NumberFormat

with the right locale, not string concatenation.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.

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.

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:

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.

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 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:

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

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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @next.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-built-a-full-stack…] indexed:0 read:5min 2026-08-15 ·