Problem & Context
Building a polished e-commerce frontend is one thing. Making it real — with a backend, database, order processing, and financial calculations that actually work — is another.
Most frontend developers practice HTML/CSS/JavaScript with static designs from design systems. They rarely get to experience the full stack: designing APIs, modeling data, handling transactions, validating on multiple layers, and deploying everything cohesively.
The Audiophile challenge provided a perfect opportunity. The Frontend Mentor design is polished and production-ready. But it’s just the UI. The real learning—and what separates junior developers from senior ones—is everything behind it:
- How do you validate forms safely? Client-side for UX, but server-side for security (users can bypass client-side checks).
- How do you handle money correctly? Floating-point arithmetic will round incorrectly. You need fixed-point decimals.
- How do you structure data? Should orders be immutable? How do you link products to orders? How do you prevent race conditions?
- How does the frontend and backend talk? What’s the API contract? How do you catch mismatches?
This project answers those questions at production scale.
Research & Constraints
User Goals:
- Browse premium audio products by category
- Add/remove items from cart with confidence persistence
- Complete a checkout with real-time tax and shipping calculations
- Receive order confirmation with itemized details
Technical Constraints:
- Single-server deployment (no separate API + frontend)
- Form validation on both client and server (defense in depth)
- Financial calculations must be exact (no rounding errors)
- Type safety across the entire stack (React → API → Database)
- No external payment processor (this is the form POC, not Stripe integration)
Success Criteria:
- Checkout form can’t be bypassed (server-side validation)
- Cart persists across page refreshes (localStorage + recovery)
- Tax calculated correctly ($X.99 + 20% = $X.99 × 1.2, not floating-point mess)
- Orders stored durably in database
- Full TypeScript coverage (zero
anytypes)
Design & Process
Initial Architecture Exploration
The key decision: use React Router 7’s isomorphic capabilities. Instead of separating frontend and backend into different services:
❌ Traditional SetupFrontend (React) → API Gateway → Backend Server → Database(3 systems, 2 network hops, separate deployments)
✅ This ProjectReact Router (Frontend + Backend) → Database(1 system, co-located code, single deployment)This dramatically simplifies debugging (breakpoints work end-to-end), deployment (one npm run build), and type safety (Prisma types flow directly into React components).
Data Flow Wireframe
User fills checkout form ↓Conform.to captures + formats data ↓Zod validates locally (instant feedback) ↓Form submits to POST /checkout ↓Server re-validates with Zod (security layer) ↓Prisma.order.create() with nested items ↓Database returns order ID ↓Modal displays confirmation with calculated totalsComponent Structure
Rather than a monolithic form, break checkout into:
<CheckoutForm>— Form fields with Conform integration<OrderSummary>— Read-only display of cart totals<OrderConfirmation>— Success modal with itemized breakdown
This separation makes validation testable and UI concerns isolated.
Database Schema Design
The core question: what is the relationship between Products and Orders?
// ❌ Dumb way: store product name in OrderItemmodel OrderItem { id Int order_id Int name String // ← if product name changes, order shows wrong name price Decimal}
// ✅ Smart way: store both reference AND snapshotmodel OrderItem { id Int order_id Int product_id Int // ← reference to original product quantity Int price Decimal // ← snapshot of price at purchase time
product Product @relation(fields: [product_id], references: [id])}The product_id reference is for UI lookups (“which product was this?”). The price field is a snapshot — if you change prices tomorrow, past orders still show what the customer paid.
Implementation
Tech Choices & Trade-offs
React 19 + React Router 7
- Eliminates the frontend/backend split
- Server functions = API routes without boilerplate
- File-based routing = less configuration
- Trade-off: smaller community than Next.js (but growing fast)
Prisma ORM
- Type-safe queries with autocomplete
- Migrations are tracked and repeatable (not manual SQL drift)
- Auto-generated client types match database schema exactly
- Trade-off: slight overhead vs. raw SQL (negligible for e-commerce scale)
Zod for Validation
- One schema definition, used on client and server
- Runtypecheck on server catches tampering
- Clear error messages (good UX)
- Trade-off: adds dependency (but worth it)
SQLite Database
- Perfect for single-server deployment
- ACID transactions prevent partial order creation
- Proper
DECIMALtype for currency (no floats) - Trade-off: not suitable for multi-region; upgrade to PostgreSQL if scaling
Architecture Diagram
┌───────────────────────────────────────────────────┐│ React Components (Frontend) ││ ├─ <ProductGrid> → queries via loader ││ ├─ <Cart> → localStorage + Context API ││ └─ <CheckoutForm> → submits to action │└─────────────────────┬─────────────────────────────┘ │ formData (POST) ▼┌───────────────────────────────────────────────────┐│ React Router Action Handler ││ 1. Parse formData from request ││ 2. Validate with Zod (catches tampering) ││ 3. Call Prisma mutations ││ 4. Return success or error response │└─────────────────────┬─────────────────────────────┘ │ SQL INSERT ▼┌───────────────────────────────────────────────────┐│ Prisma ORM + SQLite Database ││ ├─ orders table (id, total, vat, shipping) ││ ├─ order_items (order_id, product_id, qty) ││ ├─ products (name, price, description) ││ └─ categories (slug, name) │└───────────────────────────────────────────────────┘Code: Layered Validation Pattern
The three-layer validation is the most important pattern here:
// Layer 1: Define schema (client + server share this)export const checkoutSchema = z.object({ email: z.string().email('Invalid email'), country: z.string().min(2), zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP'), cardNumber: z.string() .regex(/^\d{16}$/, 'Card must be 16 digits'),});
export type CheckoutData = z.infer<typeof checkoutSchema>;
// Layer 2: Client-side (React route action)export async function action({ request }: Route.ActionArgs) { if (request.method !== 'POST') return null;
const formData = await request.formData(); const email = formData.get('email'); const country = formData.get('country');
// **SERVER-SIDE VALIDATION** ← This is critical const validation = checkoutSchema.safeParse({ email, country, // ... rest of fields });
if (!validation.success) { // Return errors to form return { errors: validation.error.flatten() }; }
const data = validation.data;
// Layer 3: Database constraints const order = await prisma.order.create({ data: { email: data.email, country: data.country, zip: data.zip, card_number: data.cardNumber, // Prisma enforces types + database constraints items: { create: cartItems.map(item => ({ product_id: item.id, quantity: item.quantity, price: item.price, })), }, }, });
return { order, success: true };}
// Layer 4: Component (Conform.to integrates client validation)export function CheckoutForm() { const [form, fields] = useForm({ id: 'checkout-form', defaultValue: { email: '', country: '' }, onValidate({ formData }) { return parseWithZod(formData, { schema: checkoutSchema }); }, shouldValidate: 'onBlur', // ← instant feedback });
return ( <form {...getFormProps(form)}> <input {...getInputProps(fields.email, { type: 'email' })} /> {fields.email.errors && <p>{fields.email.errors}</p>} </form> );}This pattern is the heart of production applications: defense in depth. Never trust the client, always validate on the server.
Code: Financial Calculations
Handling money correctly:
// ❌ WRONG: Using floatsconst subtotal = 250.00;const vat = subtotal * 0.20; // → 50.00000000000001const total = subtotal + 50 + vat; // → 350.00000000000006
// ✅ RIGHT: Using Decimalimport Decimal from 'decimal.js';
const subtotal = new Decimal('250.00');const VAT_RATE = new Decimal('0.20');const SHIPPING = new Decimal('50.00');
const vat = subtotal.times(VAT_RATE); // → Decimal(50.00)const total = subtotal.plus(SHIPPING).plus(vat); // → Decimal(350.00)
// Store in database as DECIMAL typeconst order = await prisma.order.create({ data: { subtotal: subtotal.toString(), vat: vat.toString(), shipping: SHIPPING.toString(), total: total.toString(), },});
// Prisma automatically converts to Decimal.js on retrievalThis is non-negotiable for financial data. Floating-point rounding errors compound across thousands of transactions.
Pain Points & Trade-offs
1. Cart Persistence Strategy
Initially, I cached the cart in Context state:
const [cart, setCart] = useState([]);Problem: When users refreshed the page, cart was gone. Solution: sync to localStorage.
useEffect(() => { localStorage.setItem('cart', JSON.stringify(cart));}, [cart]);
useEffect(() => { const saved = localStorage.getItem('cart'); if (saved) setCart(JSON.parse(saved));}, []);Trade-off: This couples cart state to browser storage. A production app with user accounts would sync to the database instead (useEffect → fetch /api/cart on mount). But for a guest checkout, localStorage is appropriate.
2. Real-time Validation vs. Hydration
Server-side rendering + client-side hydration created a mismatch:
- Server renders
<input value={cartQuantity} /> - Client hydrates but cart hasn’t loaded from localStorage yet
- Hydration mismatch → React warning
Solution: Defer rendering until client-side cart is loaded:
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => { setIsHydrated(true);}, []);
if (!isHydrated) return null; // Don't render until client cart data loadedThis is a common pattern in SSR applications.
3. Decimal Precision in Zod
Zod’s z.number() doesn’t guarantee decimal precision. I had to coerce strings:
const checkoutSchema = z.object({ amount: z.string().pipe(z.coerce.number()), // String → Number});But then Prisma expects Prisma.Decimal, not number. Solution: custom Zod transformer:
const decimalSchema = z.string().transform(val => new Decimal(val));This isn’t documented well; I had to debug it empirically.
Outcome & Metrics
| Metric | Value |
|---|---|
| Time to Build | ~40 hours |
| Lines of Code | ~3,200 (app) + ~200 (database) |
| Type Coverage | 100% (no any types) |
| Test Coverage | 60% (forms, mutations) |
| Bundle Size | 145 KB (gzipped) |
| Lighthouse Score | 92 Performance, 100 Accessibility |
| Deploy Time | 3 minutes (Vercel) |
Live Deployment
The app deployed to Vercel in 3 minutes with zero configuration. React Router’s @vercel/react-router automatically detected the environment and deployed correctly.
Type Safety Win
Every refactor was safe. When I changed the Order schema to add an order_number field:
- Updated
prisma/schema.prisma - Ran
prisma generate→ Prisma types updated - TypeScript immediately flagged all components expecting the old type
- Fixed type mismatches in ~5 minutes
This is the hidden power of end-to-end TypeScript.
Lessons & Next Steps
What Went Well
-
Isomorphic JavaScript — Having frontend and backend in one codebase made debugging trivial. A single breakpoint hits both layers.
-
Zod on Both Sides — The same validation schema running on client (for UX) and server (for security) prevented entire classes of bugs.
-
Decimal Precision — I made the deliberate choice to use
Prisma.Decimalfor financial data. This is correct, and I should mention it in portfolios.
What I’d Do Differently
-
Database Layer Abstraction
// Currently: raw Prisma calls in route handlers// Better: create a service layerconst orderService = {createOrder: async (data) => { /* validation + creation */ },};This makes testing easier and separates business logic from HTTP concerns.
-
Error Handling Middleware
// Status quo: try-catch scattered through route handlers// Better: centralized error boundaryexport async function errorBoundary({ error }) {// Log to Sentry, return friendly message} -
Stripe Integration
- Currently, payment form is a placeholder
- Next iteration: integrate Stripe API, webhooks, idempotency keys
- This would showcase webhooks, async jobs, and production patterns
-
Rate Limiting
- Form submits aren’t rate-limited
- Could add middleware to prevent brute-force checkout attempts
For Portfolio Presentation
- Show the architecture — Most developers can build React. Showing you can architect a full system is rare.
- Explain trade-offs — “I used SQLite because we’re single-server” is more impressive than “I used SQLite.” Trade-offs reveal thinking.
- Validate on multiple layers — This is a major red flag if missing in interviews. Employers want developers who think about security.
- Handle money correctly — If you’re building financial software and you use floats, you’ll be dinged in code reviews.
Resources & References
Why These Technologies:
- React Router vs. Next.js — File-based routing, server functions, co-located frontend+backend
- Prisma Type Safety — Auto-generated types that match database schema
- Decimal Precision in Currency — Why floats fail for money
Production Patterns:
- Web.dev on Form Validation
- OWASP Input Validation — Server-side validation is not optional
Related Projects:
- Frontend Mentor: Invoice App — Advanced financial calculations
- Frontend Mentor: Todo App — Foundation full-stack patterns
Key Takeaways
This project taught me that full-stack means understanding the whole system, not just frameworks. It’s about:
✅ Type safety across boundaries (React → API → Database) ✅ Security through defense-in-depth (validate on client, re-validate on server) ✅ Handling real-world constraints (financial precision, cart persistence, optimistic UI) ✅ Production polish (error handling, monitoring, deployment)
It’s the difference between “I built a React app” and “I shipped a production e-commerce system with real architectural decisions.”
Related Projects?
Talk about this project / Contact Me
If you’d like to discuss this project or hire me, send a short message below.