Snakeoil Audio

Under final review

Snakeoil Audio
Snakeoil Audio
  • Next.js
  • Tailwind
  • Next-Auth
  • bcrypt
  • Upstash Redis
  • @upstash/ratelimit
  • MongoDB
  • Mongoose
  • MDX
  • React Hook Form
  • Zod
  • Zustand
Type
Academic
Category
Web App
Date
2025-12

DEMO AND SOURCE CODE COMING SOON

After a few months away from writing code, I started building a small store using React and Express as a practical exercise.
Then I stumbled upon Next.js: a framework that was new to me, but familiar thanks to React, so I decided to scrap everything and start over. I wanted to understand how a fullstack app is designed when frontend, APIs, and the database live inside the same project — and, yes, also use my other favorite language after TypeScript: sarcasm.

The UI is intentionally minimal. That’s not a bug, it’s a choice: the focus of the project is application logic.

1. Why rebuilding it with Next.js

Working with Next.js forced me to rethink the workflow I was used to with React + Express. Frontend and backend are no longer two separate entities: here routing, rendering, and APIs coexist in the same repository, which made the development process faster and more intuitive for me.

I wasn’t looking for the most elegant solution possible. I wanted an end-to-end project that actually works, with authentication, cart management, and even license logic to prevent duplicate purchases. Everything else — visuals, CMS, real payments — was deliberately out of scope.

2. Architecture and cart design choices

The architecture is intentionally simple:

  • Client: dynamic product pages built with MDX, cart state management using Zustand, UI interactions, and local persistence.
  • Server (API Routes): data validation and enforcement of duplicate-purchase rules.
  • Database (MongoDB): persistence of users and orders.
  • Proxy: rate limiting with Upstash Redis.

For the cart, I made a pragmatic decision: it lives exclusively in localStorage, persisted via Zustand middleware. Each product can only be added if it’s not already present, avoiding client-side duplicates.

I considered a server-side cart sync, but the store only sells four products. Synchronizing the cart felt like overengineering. To avoid duplicate purchases, however, when a user logs in the server checks their order history and removes any already-owned products from the local cart. It’s not a full synchronization, but it’s sufficient for the scope of the project and keeps the architecture lightweight.

// src/lib/useCart.ts

import { create } from "zustand";
import { persist } from "zustand/middleware";
import { CartState } from "../types/cart-types";

export const useCart = create<CartState>()(
	persist<CartState>(
		(set, get) => ({
			items: [],
			addItem: (item) => {
				const existing = get().items.find((i) => i.id === item.id);
				if (!existing) {
					set({ items: [...get().items, item] });
				}
			},
			removeItem: (id) => {
				set({ items: get().items.filter((i) => i.id !== id) });
			},
			reset: () => set({ items: [] }),
		}),
		{ name: "cart-storage" }
	)
);

3. Checkout, validation and business rules

The part I enjoyed the most was implementing the “one-time purchase” rule for software licenses. For each product, the system checks whether it already exists in the user’s order history. If the user already owns a product, they can’t add it to the cart again. In the case of a guest user, when logging in (required to complete a purchase), any already-owned products are automatically removed from the cart.

I could have added a toast notification here, but I decided it would be a more UI-oriented feature and therefore outside the scope of this project.

// server-side check if user already owns the product

const ownsProduct = !!(await Order.exists({ user: session.user.id, status: "paid", "items.item": productData.id }));

For registration and login form validation, I used Zod as a single source of truth. The same schemas are shared between client-side forms and API routes, which made debugging easier and helped prevent issues caused by malformed payloads.

4. Security and limits

Even in a demo project, I wanted to address some practical security concerns: rate limiting with Upstash Redis, password hashing with bcrypt, and authentication via NextAuth. When a user exceeds the request threshold, the app displays a dedicated page instead of exposing a raw technical error — a small UX detail that makes the app feel more polished (while reinforcing the company’s well-known lack of generosity).

too many requests page

5. What I learned

This project taught me that documentation often leaves plenty of unanswered questions, and that simple choices are valuable when they’re well justified. Building an end-to-end flow exposes real (and frustrating) problems that never show up in curated tutorials. SnakeOil Audio isn’t perfect, but it was fun to build, helped me get back into shape, and gave me a much clearer picture of what it means to work on a fullstack application with Next.js.