Skip to content

Build1 publisher3 min readPublished

Two Next.js apps instead of one, because isAdmin is a privilege escalation waiting to happen

A dev.to build log splits storefront from admin on authentication grounds, not bundle grounds. The stronger version of the argument is about which database records are allowed to be shared.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying Two Next.js apps instead of one, because isAdmin is a privilege escalation waiting to happen
Generated illustration

What happened

  • A developer, Mohamed Djoudir, writing on dev.to, describes shipping a production e-commerce system as a single monorepo containing a Next.js 16 storefront, a Next.js 16 admin dashboard, and a NestJS API.
  • The author states the obvious approach is one Next.js app with /admin routes behind a guard, and that he split it into three instead.
  • Per the author, the storefront is public, SEO-critical and cached aggressively, while the admin is authenticated on every request, data-heavy, and never indexed.
  • The author states that sharing a build means the storefront pays for Recharts, the data grids, and the admin's client bundle even when nobody signs in.
  • The author states that splitting made auth honest: customers and staff are different tables with different token payloads and different refresh lifetimes.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A developer writing on dev.to has published the build log for an e-commerce system shipped as one monorepo containing three deployables: a Next.js 16 storefront, a Next.js 16 admin dashboard, and a NestJS API [1]. The part worth arguing about is not the bundle arithmetic, it is the author's claim that keeping staff and customers in one app drags you toward a single User model carrying an isAdmin boolean, and that role-based access control built on top of a boolean is how privilege escalation bugs happen [7].

The default in this stack is one app with /admin routes behind a guard, and the author says he rejected it [3]. The performance case he makes first is the familiar one. The storefront is public, SEO-critical and aggressively cached, while the admin is authenticated on every request, data-heavy and never indexed [4]. Share a build and the storefront ships Recharts, the data grids and the rest of the admin client bundle to visitors who will never sign in [5].

That argument is real but weak, because bundles can be split without splitting apps. The load-bearing claim is the second one: in his design, customers and staff are separate tables with separate token payloads and separate refresh lifetimes [6]. Note what that buys. A boolean column can be flipped by any code path that can write to the user row, and every query that forgets a where clause returns both populations. Two tables mean the storefront's token cannot name a staff identity at all, because the identity does not exist in the table the storefront reads. The author presents this as a design principle rather than as a postmortem of a specific breach [7], so treat it as reasoning, not as evidence.

The same instinct shows up in the order model, which is where it gets easier to check. Because guest checkout was a requirement, order.userId is nullable and the order carries its own contact and shipping snapshot, rather than every guest purchase minting a phantom user row that corrupts customer analytics [10]. The shipping address on an order is a copy, not a foreign key, so editing a saved address later does not rewrite historical orders [11]. Line items denormalise product name, variant and unit price at purchase time, because prices change and invoices should not [12]. That is the same refusal as the auth split: do not let one row serve two lifetimes.

The stated cost is three ports in local development, a shared types package, and CORS configuration you have to actually think about [8]. Only one of those three is dev-only; the types package and the CORS surface are permanent [22]. Guest order lookup is order number plus checkout email, rate-limited so it does not become an enumeration oracle [13], which is the kind of detail that suggests the threat model was applied past the login screen.

What to watch is drift. Two identity tables with different refresh lifetimes [6] means two auth implementations to patch, and the shared types package [8] is the seam where the separation will quietly leak back. On the AI side, the controls to check are the per-account server-side quotas on image generation and the cache keyed on garment plus model pair, which the author says covers most traffic [19][20].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories