Premium Indian tailoring & fashion e-commerce — Next.js 16 App Router storefront with two parallel admin panels sharing one SQLite backend.
Live storefront: web-two-mocha-61.vercel.app Admin panels: local-only (SQLite needs a writable filesystem — Vercel won't work, see Deployment).
This repo runs Next.js 16.2.4 with breaking changes from earlier versions:
| What changed | Impact |
|---|---|
middleware.ts → proxy.ts |
Same behaviour, new filename. Both admin panels are gated by proxy.ts at the repo root. |
cookies() is async |
const c = await cookies() everywhere. |
| Server actions sit next to pages | app/admin/actions/*.ts, app/studio/actions/*.ts. |
| Native modules need allow-listing | next.config.ts has serverExternalPackages: ["better-sqlite3"]. |
Before writing code: check node_modules/next/dist/docs/ for the canonical Next 16 patterns. Don't rely on Next 14/15 muscle memory.
Both panels read & write the same data/admin.db via the same session cookie (ezj_admin_session). Pick the one whose vibe fits the operator.
Editorial, oxblood-on-cream "leather workbook". Cormorant Garamond italics, mono kickers, hairline rules, zero radii. Designed for the brand owner who wants the back-office to feel like a tailor's notebook, not a SaaS dashboard.
- 16 routes, 9 server actions
app/admin/styles/admin.csscarries the design tokens
Pragmatic, productivity-first. Dense tables, drag-to-reorder lists, image uploaders with crop, toast notifications. Designed for staff doing high-volume catalogue/banner/offer work.
- 20 routes, 9 server actions
app/studio/styles/studio.css+react-image-cropfor media handling
Same data, two ways of looking at it. You can run them side-by-side or pick one and delete the other; the lib layer doesn't care.
npm install # installs better-sqlite3, bcryptjs, sharp, puppeteer-core, etc.
npm run dev # → http://localhost:3000 (or :3001 if 3000 is busy)Default credentials (auto-seeded on first DB open — works for both /admin and /studio):
email: admin@elitezonej.com
password: admin123
These are baked into lib/admin/db.ts (ensureDefaultAdmin). Change the password through /admin/settings after first login — the seed only runs if the account is missing, so your new password sticks.
First boot:
npm run dev- Visit
/admin/login(or/studio/login) → log in with the creds above - The DB at
data/admin.dbis auto-created with:- The default owner user
- 30 SKUs seeded from
lib/products.ts - 25 mock orders, 18 customers, 6 bookings, 4 promo codes (so dashboards aren't empty)
- Default homepage blocks, banners, notices
Storefront stays on the static catalog (lib/products.ts); admin edits do not yet propagate to the public homepage. The DB-driven storefront layer (app/components/storefront/) is wired and tested but not the default — see Future work.
┌──────────────────┐
│ proxy.ts │ gates /admin/* and /studio/*
│ (root) │ on the SESSION_COOKIE
└────────┬─────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐
│ /admin │ │ /studio │ │ / │
│ atelier │ │ shopify │ │ storefront │
│ ledger │ │ canva │ │ (public) │
└─────┬──────┘ └─────┬──────┘ └──────┬───────┘
│ │ │
└──────────┬───────────┘ │
▼ │
┌───────────────┐ │
│ lib/admin/ │ │
│ ───────── │ │
│ db.ts │ better-sqlite3 ◄───────┘ (storefront reads
│ auth.ts │ singleton use lib/storefront/
│ session.ts │ which optionally hits
│ repos/*.ts │ the same DB)
│ schema.sql │
│ schema-v2.sql │
└───────┬───────┘
▼
┌───────────────┐
│ data/admin.db │ (gitignored, WAL mode)
└───────────────┘
- Synchronous. No
awaiton queries. Fits Server Components and Server Actions cleanly. - Singleton on
globalThis.__ezj_admin_dbso dev hot-reload doesn't open a new handle every render. - Idempotent bootstrap in
lib/admin/db.ts:schema.sql(v1) loaded withCREATE TABLE IF NOT EXISTSon every open.schema-v2.sqlapplied statement-by-statement soALTER TABLE ADD COLUMNcan be skipped if the column already exists.- Empty DB → seeds catalog (
seed.ts), fixtures (seed-fixtures.ts), and studio defaults (seed-studio.ts).
- WAL mode + foreign keys ON. DB file lives at
data/admin.db(gitignored).
- Hashing:
bcryptjs(pure-JS, no native build) at cost 12. - Sessions: server-side rows in
sessionstable; cookieezj_admin_sessionholds only the session id (32 random bytes, hex).httpOnly,sameSite=lax,securein prod, 30-day TTL. - Bootstrap: if
usersis empty, every gate redirects through/admin/setup(or/studio/setup). After bootstrap, the setup page returns 404. - Three-layer authorization:
proxy.ts— coarse "logged in or not" gateapp/{admin,studio}/layout.tsx— fetches the session user once viagetSessionUser(sid)and passes to children- Per-action
requireRole('owner'|'staff')before any mutation
Each domain gets one file under app/admin/actions/ or app/studio/actions/. Pattern:
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { requireUser } from "../../../lib/admin/session";
const Schema = z.object({ /* … */ });
export async function upsertProduct(formData: FormData) {
const me = await requireUser();
const data = Schema.parse(Object.fromEntries(formData));
// …repo write…
revalidatePath("/admin/products");
}The repos/ modules are the only place that touches SQL. Pages and actions call repos; nothing else.
app/admin/
page.tsx Dashboard — KPIs, sparkline, low-stock, recent bookings
login/page.tsx
setup/page.tsx First-run owner bootstrap (auto-disabled after)
products/{,new/,[slug]/} Catalogue editor — Details · Inventory · Media · Pricing
fabrics/{,new/,[slug]/} Fabric SKUs + colourway matrix (hex picker, stock meters)
inventory/page.tsx Global SKU × size stock grid
orders/{,[id]/} Order list + detail with status flow
customers/{,[id]/} Customer list + history
bespoke/{,[id]/} Booking inbox — new → contacted → scheduled → done
categories/page.tsx Category tree editor
promotions/{,new/,[code]/} Promo codes (percent / flat / free_ship)
content/page.tsx Hero / Editorial / Process / MFY / Banner / Trust copy
media/page.tsx /public/generated explorer
settings/page.tsx Brand · Account · Users · Audit log
app/studio/
page.tsx Operator dashboard — open orders, drafts, quick actions
login/page.tsx
setup/page.tsx
products/{,new/,[slug]/} Catalogue with multi-image upload + crop
banners/{,new/,[id]/} Hero banners (desktop + mobile, schedule, target)
notices/{,new/,[id]/} Ticker / popup / festive notices
homepage/{,[id]/} Composable block builder (drag to reorder)
categories/{,new/,[id]/} Category editor with images + visibility toggle
offers/{,new/,[code]/} Offer codes with target audience matrix
flash-sales/{,new/,[id]/} Countdown banners tied to promo codes
orders/{,[id]/} Order workbench
customers/{,[id]/}
bespoke/{,[id]/}
media/page.tsx Real media library (uploads → /public/admin-uploads)
settings/page.tsx
| Table | What it stores | Key columns |
|---|---|---|
users |
Admin operators | email (unique), password_hash, role (owner/staff) |
sessions |
Active logins | id (uuid), user_id, expires_at |
products |
Catalogue (tailored + fabric) | slug (PK), kind, status, price, sale_price |
inventory |
Stock per size | (product_slug, size) PK, stock, oos_flag |
fabric_meta |
Fabric specs | width_inches, gsm, composition, stock_meters_total |
fabric_colours |
Colourways | name, hex, stock_meters, image_dir |
customers |
Buyer records | email (unique), total_orders, total_spent |
orders |
Order ledger | id (WK-####), status, total, currency |
order_items |
Line items | order_id, product_slug, qty, unit_price, size, colour |
bookings |
Bespoke leads | service, status, assigned_to |
categories |
Nav tree | parent_id self-ref, slug, gender, kind, image_path |
promotions |
Promo codes | code (PK), type, value, usage_limit, is_featured |
offer_targets |
Promo audience | target_type (all/category/product), target_id |
flash_sales |
Countdown banners | promo_code FK, ends_at |
home_sections |
Legacy homepage copy | key (PK), title, body, image_path |
homepage_blocks |
Composable home blocks | type, config_json, sort_order |
banners |
Hero banners | image_path, mobile_image_path, text_align, status |
notices |
Tickers / popups | type, priority, target_paths |
product_images |
Multi-image gallery | product_slug, image_path, sort_order, is_thumbnail, is_hover |
product_meta |
Flags + SEO | is_featured, is_trending, is_new_arrival, meta_title |
media_assets |
Upload tracking | path (unique), folder, mime, uploaded_by |
settings |
Brand config | key (PK), value |
audit_log |
Mutation trail | user_id, action, entity, entity_id, payload_json |
Schema files: lib/admin/schema.sql (v1), lib/admin/schema-v2.sql (additions).
- Create
app/{admin|studio}/<route>/page.tsx. - Make it a server component — read with
listX()from a repo. - For mutations, drop a server action in
app/{admin|studio}/actions/<domain>.tsand pass it asaction={…}to a<form>. - The layout already provides the shell — just return the page body.
- Append to
lib/admin/schema-v2.sql(useALTER TABLE … ADD COLUMN; theapplyV2runner skips columns that already exist). - Update the matching repo + types in
lib/admin/types.ts. - Either delete
data/admin.dbfor a clean reseed, or just restartnpm run dev— the migration runs idempotently.
Drop a file under lib/admin/repos/. Keep all db.prepare(...) SQL inside repos — pages and actions should never import getDb() directly.
- Add the type string to
homepage_blocks.type(no enum constraint — it's free-form text). - Create the React component under
app/components/storefront/blocks/. - Wire it into the switch in
app/components/storefront/HomepageRenderer.tsx. - Build a
/studio/homepage/[id]editor section for itsconfig_jsonshape.
When you can't deploy and need to show the client what the panels look like:
# 1. Boot the dev server
npm run dev
# 2. Plant a session cookie (one-shot — keeps script unauthenticated-free)
node -e '
const Db = require("better-sqlite3");
const { randomBytes } = require("crypto");
const fs = require("fs");
const db = new Db("data/admin.db");
const id = randomBytes(32).toString("hex");
const exp = new Date(Date.now() + 86400e3).toISOString();
db.prepare("DELETE FROM sessions").run();
db.prepare("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,1,?)").run(id, exp);
fs.writeFileSync("/tmp/ezj_session_id", id);
'
# 3. Capture 41 routes × 2 viewports = 82 PNGs (uses system Chrome via puppeteer-core)
node scripts/admin-screenshots.mjs
# 4. Output: /tmp/admin-screenshots/{admin,studio,storefront}/{desktop,mobile}/*.pngThe script reads BASE_URL (defaults to http://localhost:3001) and writes full-page screenshots at 1440×900 @2x and 390×844 (iPhone UA).
better-sqlite3 would normally need a writable filesystem, but lib/admin/db.ts detects process.env.VERCEL === "1" and switches to a :memory: SQLite database. Same schema, same seeds, same default admin@elitezonej.com / admin123 login — it just resets on every cold start (≈15-min idle timeout, or any redeploy).
What you get on Vercel:
/studioand/admindeploy and render exactly like local- Login with the default credentials works
- Mutations work during a session — the same lambda instance keeps your edits in RAM until it spins down
- A second concurrent visitor hitting a different lambda instance gets their own fresh in-memory DB
This is demo-grade, not production. For a real persistent host, see Persistent options below.
If you need real writes that survive cold starts:
| Host | Why it works |
|---|---|
| Railway | Persistent volume, Node runtime, simple Git-deploy |
| Fly.io | Volumes + always-on machines |
| Cloudflare Tunnel to your own server | Lowest cost, full control, real disk |
| Self-hosted VPS (DigitalOcean / Hetzner) | Vanilla npm run start behind nginx |
For any of these, leave VERCEL and IS_SERVERLESS unset so the panel uses the on-disk DB. Mount data/ on a persistent volume.
The public site under /, /collection, /products/[slug], /bespoke reads from the static lib/products.ts and deploys cleanly regardless of the SQLite mode.
| Layer | Choice |
|---|---|
| Framework | Next.js 16.2 App Router (Turbopack) |
| UI | React 19, hand-written CSS (no UI lib) |
| Database | SQLite via better-sqlite3 |
| Auth | bcryptjs + DB-backed sessions |
| Validation | zod in server actions |
| Image processing | sharp (resize/optimize on upload) |
| Image cropping | react-image-crop (studio only) |
| Screenshots | puppeteer-core + system Chrome |
| Language | TypeScript strict |
elitezonej/
├── proxy.ts Auth gate for /admin/* and /studio/*
├── next.config.ts serverExternalPackages: ["better-sqlite3"]
├── data/ SQLite home (gitignored)
│ └── admin.db Auto-created on first run
│
├── app/
│ ├── page.tsx Storefront homepage (HeroGrid, CarouselShowcase, EditorialSplit, MadeForYou…)
│ ├── components/ Storefront components (Header, Footer, ProductCard, …)
│ │ └── storefront/blocks/ DB-driven block components (optional alt homepage)
│ │
│ ├── admin/ Atelier Ledger panel
│ │ ├── layout.tsx Auth gate + sidebar/topbar shell
│ │ ├── page.tsx Dashboard
│ │ ├── styles/admin.css Design tokens (oxblood, cream, Cormorant)
│ │ ├── components/ AdminSidebar, KpiTile, Folio, StatusPill, …
│ │ ├── actions/ Server actions (auth, products, fabrics, orders, …)
│ │ └── <domain>/page.tsx One folder per operator surface
│ │
│ └── studio/ Shopify+Canva panel
│ ├── layout.tsx ToastProvider + StudioShell
│ ├── styles/studio.css
│ ├── components/ StudioShell, ImageUploader, SortableList, Toast, …
│ ├── actions/
│ └── <domain>/page.tsx
│
├── lib/
│ ├── products.ts Static catalog (storefront source of truth + seed source)
│ ├── admin/
│ │ ├── db.ts better-sqlite3 singleton + bootstrap
│ │ ├── auth.ts bcrypt + session helpers
│ │ ├── session.ts requireUser(), requireRole()
│ │ ├── schema.sql v1 DDL (idempotent)
│ │ ├── schema-v2.sql v2 additions (idempotent ALTERs)
│ │ ├── seed.ts First-run catalog seed from lib/products.ts
│ │ ├── seed-fixtures.ts 25 orders, 18 customers, 6 bookings, 4 promos
│ │ ├── seed-studio.ts Default homepage blocks, banners, notices
│ │ ├── types.ts Shared TS types
│ │ ├── kpi.ts Dashboard aggregations
│ │ ├── format.ts ₹ formatter, date formatter
│ │ └── repos/ 19 repos — one per domain, only files that touch SQL
│ └── storefront/ DB-aware reads for the public site (optional)
│
├── public/
│ ├── generated/ Product imagery (5 angles × 30 SKUs + section images)
│ └── admin-uploads/ Operator uploads (gitignored)
│
└── scripts/
└── admin-screenshots.mjs Puppeteer-driven panel screenshot capture
- Port 3001 fallback — if anything is bound to 3000 (Brave keeps stale connections open after closing tabs), Next switches to 3001 silently. Update bookmarks and check the dev server output.
cookies()is async —await cookies()everywhere.cookies().get(...)synchronous use will throw at runtime.- DB file is gitignored — every clone gets a fresh seed on first
npm run dev. To share state, copydata/admin.dbmanually (it's a single file). - Two cookies, one auth —
ezj_admin_sessionandezj_studio_sessionboth exist as cookie names in seeds, but the active gate is justezj_admin_session. Logging into either panel logs you into both. - Schema v2 ALTER pattern — never delete a column from
schema-v2.sql. The DB might already have it; we silently skip. To remove a column you have to write a v3 migration that does the table-rebuild dance. - Storefront ↔ admin disconnect — admin edits today don't change the public homepage; the storefront still reads
lib/products.ts. The DB-backed renderer (HomepageRenderer.tsx) exists and works, butapp/page.tsxdeliberately stays on the static editorial layout. Future task: switch/collectionand/products/[slug]tolib/storefront/products.ts.
- Wire the public storefront (
/,/collection,/products/[slug]) to read fromlib/storefront/products.tsso admin edits go live. - PDF invoice generation (
app/api/admin/orders/[id]/invoice/route.tsis stubbed to return JSON). - Email notifications on
bookings.status = scheduledandorders.status = shipped. - Move
data/admin.dbto a managed host (Turso / Cloudflare D1) so the panels can run on edge.
npm run dev # Next dev server (turbopack)
npm run build # production build
npm run start # production serverNo tests yet; verification is manual against the 12-step checklist that lived in the original plan (login → seed → CRUD round-trip on each domain → bespoke booking from /bespoke lands in /admin/bespoke).
Built for Elite Zone J — Delhi atelier, made-to-measure.
Owner contact: see settings table after first-run setup.