A full-stack e-commerce platform consisting of two Next.js applications: a multi-tenant admin dashboard (CMS + API backend) and a customer-facing online store. The admin provides complete CRUD management for products, categories, sizes, colors, billboards, and orders with Cloudinary image uploads and revenue analytics. The store consumes the admin's public API to display products with filtering, an image gallery, persistent shopping cart, and Stripe checkout. Built with Next.js 15, React 19, Prisma ORM, MySQL, Clerk, Stripe, and Cloudinary.
How the system is structured from frontend to external services.
Multi-tenant dashboard scoped by [storeId] route parameter. Server Components for data fetching, Client Components for forms (React Hook Form + Zod), data tables, modals, and the store switcher (cmdk). Dark/light/system theme toggle via next-themes. Zustand manages the store creation modal state.
RESTful API routes serving both the admin dashboard and the store frontend. GET endpoints are public (no auth) for the storefront to consume. POST/PATCH/DELETE endpoints require Clerk authentication and verify store ownership. Checkout endpoint creates Stripe sessions, webhook endpoint processes payments and archives products.
Customer-facing storefront with no database of its own — all data fetched from the admin API via server-side fetch() calls in action files. Zustand with localStorage persistence manages the shopping cart. Headless UI provides the product gallery (TabGroup), preview modal (Dialog with transitions), and mobile filter drawer.
Nine Prisma models with relationMode: prisma for PlanetScale compatibility. Cloudinary handles product and billboard image uploads via next-cloudinary widget. Stripe processes payments with hosted checkout and webhook-driven order finalization. All entity IDs are UUIDs.
Core entities and how they relate to each other.
Multi-tenant store — each user can create multiple stores, and all content is scoped to a store via storeId foreign keys
id (uuid, PK)nameuserId (from Clerk)createdAtupdatedAtHero banner images displayed at the top of the store — each billboard has a label and a Cloudinary image URL
id (uuid, PK)storeIdlabelimageUrl@@index([storeId])Product categories linked to a billboard for visual display — categories drive the store's navigation menu
id (uuid, PK)storeIdbillboardIdname@@index([storeId])@@index([billboardId])Product sizes (e.g., S, M, L, XL) — each size has a name and display value
id (uuid, PK)storeIdnamevalue@@index([storeId])Product colors with hex values — displayed as colored circles on the store frontend and as filter buttons
id (uuid, PK)storeIdnamevalue (hex string)@@index([storeId])Main product entity with price, featured/archived flags, and links to category, size, and color. Archived products are hidden from the store and auto-archived after purchase
id (uuid, PK)storeIdcategoryIdsizeIdcolorIdnameprice (Decimal)isFeatured (default: false)isArchived (default: false)Product images uploaded via Cloudinary — supports multiple images per product for the gallery view
id (uuid, PK)productIdurl@@index([productId])Customer order record — created during Stripe checkout, updated to paid with address/phone after webhook confirms payment
id (uuid, PK)storeIdisPaid (default: false)phone (default: '')address (default: '')@@index([storeId])Individual product within an order — links an order to a product for the order details
id (uuid, PK)orderIdproductId@@index([orderId])@@index([productId])Step-by-step walkthrough of the main user and system flows.
The store frontend needs product data but shouldn't have its own database or duplicate the admin's data models. Both apps need to run independently and communicate reliably.
The admin serves a dual role: CMS dashboard AND public API backend. GET endpoints are unauthenticated so the store can fetch data server-side. The store's action files are thin fetch() wrappers pointing to NEXT_PUBLIC_API_URL. This means one source of truth for data, and the store can be deployed anywhere as a static/SSR frontend.
When editing a product, the user might add, remove, or reorder images. Tracking individual image adds/removes against the database is complex and error-prone.
Used a delete-all-then-recreate pattern: the PATCH endpoint first runs images.deleteMany({}) to remove all existing images, then images.createMany({data: newImages}) to insert the new set. This is simpler than diffing and works because Image cascade-deletes are handled by Prisma. The trade-off is slightly higher write volume, but it eliminates sync bugs.
Multi-tenant apps must ensure users can only modify their own stores. A simple userId check on the request isn't enough — the user might craft a request to a storeId they don't own.
Every mutating API route performs a two-step auth check: (1) Clerk auth() extracts the userId from the JWT, (2) prismaDB.store.findFirst({where: {id: storeId, userId}}) confirms the user owns the specific store. If either check fails, the route returns 401/403. This prevents cross-tenant data manipulation.
Orders need to be created before payment (to track what was purchased), but they shouldn't be marked as paid until Stripe confirms payment. Products should also become unavailable after purchase to prevent overselling.
The checkout endpoint creates an Order with isPaid: false and OrderItems linking to each product. Stripe's checkout.session.completed webhook then updates the order (isPaid: true, captures address/phone from the session). The webhook also auto-archives all purchased products (isArchived: true), which hides them from the store's product listing since the API always filters isArchived: false.
StoreCraft demonstrates a complete e-commerce platform with a dual-app architecture: a multi-tenant admin CMS that doubles as an API backend, and a separate storefront that consumes the public API. The project showcases multi-store management with Clerk authentication and ownership verification, Cloudinary image uploads, Stripe checkout with webhook-driven order lifecycle, persistent cart state with Zustand and localStorage, and a consistent CRUD pattern across all entities using TanStack React Table, React Hook Form, and Zod validation.