princemuel
RSS
Just a dev
Engineering

Audiophile E-Commerce Platform


kettanaito's avatar

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:

This project answers those questions at production scale.

Research & Constraints

User Goals:

Technical Constraints:

Success Criteria:

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 Setup
Frontend (React) → API Gateway → Backend Server → Database
(3 systems, 2 network hops, separate deployments)
✅ This Project
React 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 totals

Component Structure

Rather than a monolithic form, break checkout into:

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 OrderItem
model OrderItem {
id Int
order_id Int
name String // ← if product name changes, order shows wrong name
price Decimal
}
// ✅ Smart way: store both reference AND snapshot
model 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

Prisma ORM

Zod for Validation

SQLite Database

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 floats
const subtotal = 250.00;
const vat = subtotal * 0.20; // → 50.00000000000001
const total = subtotal + 50 + vat; // → 350.00000000000006
// ✅ RIGHT: Using Decimal
import 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 type
const 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 retrieval

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

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 loaded

This 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

MetricValue
Time to Build~40 hours
Lines of Code~3,200 (app) + ~200 (database)
Type Coverage100% (no any types)
Test Coverage60% (forms, mutations)
Bundle Size145 KB (gzipped)
Lighthouse Score92 Performance, 100 Accessibility
Deploy Time3 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:

  1. Updated prisma/schema.prisma
  2. Ran prisma generate → Prisma types updated
  3. TypeScript immediately flagged all components expecting the old type
  4. Fixed type mismatches in ~5 minutes

This is the hidden power of end-to-end TypeScript.

Lessons & Next Steps

What Went Well

  1. Isomorphic JavaScript — Having frontend and backend in one codebase made debugging trivial. A single breakpoint hits both layers.

  2. Zod on Both Sides — The same validation schema running on client (for UX) and server (for security) prevented entire classes of bugs.

  3. Decimal Precision — I made the deliberate choice to use Prisma.Decimal for financial data. This is correct, and I should mention it in portfolios.

What I’d Do Differently

  1. Database Layer Abstraction

    // Currently: raw Prisma calls in route handlers
    // Better: create a service layer
    const orderService = {
    createOrder: async (data) => { /* validation + creation */ },
    };

    This makes testing easier and separates business logic from HTTP concerns.

  2. Error Handling Middleware

    // Status quo: try-catch scattered through route handlers
    // Better: centralized error boundary
    export async function errorBoundary({ error }) {
    // Log to Sentry, return friendly message
    }
  3. 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
  4. Rate Limiting

    • Form submits aren’t rate-limited
    • Could add middleware to prevent brute-force checkout attempts

For Portfolio Presentation

Resources & References

Why These Technologies:

Production Patterns:

Related Projects:

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.