Back to posts

5 mistakes I've seen beginners make in ecommerce apps

Recently I've been doing code reviews more than usual. It prompted me to write this post since I've noticed some behavior that should be avoided.

I normally scoff at the "X things bla bla" type of posts since I see them a lot... And now I'm writing one... We grow each day :)

In no particular order of importance... Some can cost real money, others may seem a little more minor on their own, but they're all connected in one way or another. I'll try to explain them from the business and architectural point of view, since we're mostly dealing with money here, so we should be a little extra careful.


1. The checkout shouldn't trust the client when it comes to money

We're on the checkout page. The user entered their data, chose the shipping and payment methods, and the page shows the items, prices, quantity, discounts, coupon codes, totals, etc.

I get it - the temptation to just send all that data in a POST request, and call it a day. I mean, it's all already there!

We **must** resist this temptation.

Let me back up a little. I'm talking about a B2C shop where it's possible to make a purchase without an account (no login) - as it should be. But no login also means there's no session to hide behind, and even if there were one, it wouldn't help - whoever holds the browser holds the cookie. Which means someone could theoretically open up devtools, see how data is being sent, and replicate it, but with some changes. They could type something like this in the console:

1fetch("/api/checkout", {
2 method: "POST",
3 headers: { "Content-Type": "application/json" },
4 body: JSON.stringify({
5 fullName: "John Doe", email: "johndoe@example.com", phone: "060111222",
6 city: "NY 11550", address: "7132 Laurel Lane, Hempstead",
7 deliveryMethod: "1", paymentMethod: "1",
8 cartItems: [{ name: "Corsair 32GB DDR5 RAM", quantity: 1, discountedPrice: 10 }],
9 }),
10});

And if you don't have any server-side validation and just accept this as-is, they've technically bought 32GB of RAM for $10, since nothing in this flow disagrees with it (especially problematic when combined with issue #3).

The rule of thumb is: Client-side prices are for display only. Server-side prices are for money.

You should only send identifiers from the client, which the server uses for calculating everything else.

And since we're talking about the cart here, which should always be saved in the db anyway (I'll address this a little more later), we don't even need cartItems in the checkout request at all. We just send the user's shipping data (name, address, phone, etc.) and the server should look up their cart in the db based on their identity.


2. One cart, one owner

Notice how I ended the previous sentence with "identity" and not "login" or "account". Just because the user doesn't need to create an account doesn't mean the backend shouldn't know who they are somehow. Otherwise, how is it supposed to know which cart items belong to which user? localStorage is not enough, especially for important things like the cart.

If the user doesn't have an account, we could, for example, generate a random user_token for them which then gets stored in their cookies, and we can use that to assign cart items to them.

Here's a thing I've noticed more than once... There are API requests implemented for adding items to the cart, as well as removing them, but then, modifying the quantity in the cart doesn't get sent to db, only to localStorage. So for example, the user updates a quantity in the cart, refreshes the page - and the quantity is back to the previous value. There's no longer a single source of truth (which should always be the db here).

If you were to guess how or why, you might say they were rushing a bit and only thought of the happy path scenario when the user would update the quantity, enter data, and complete the purchase. That way the submit would pick up the quantity and they would save one trip to the db.

Personally, I think it's more likely to be due to the fact that the frontend demo was done before the backend was complete and they used localStorage for the demo, and just forgot to include the quantity change when they were implementing the API. Doesn't really matter why. We need to be careful not to forget these things when upgrading features.

When the cart is always kept up-to-date in the db, we have a reliable source of truth, which can help us implement cart-related features across different pages if we need to (like a basket widget or preview), and more importantly, help us reduce security risks like #1.

So, one owner for cart data, cookie for anonymous identity, db for rows, localStorage for UI fluff you can lose. After mutations, invalidate whatever query key owns the cart (if you're using TanStack Query - my favorite tool for client-side fetching).


3. Email-only checkout is valid for MVP only - not a forever plan

Speaking of forgetting things and half-migrations - this is another one I've seen.

Order is completed, checkout request is sent, API does its thing, sends an email, and returns { success: true }. No orders table.

Now I understand this is not technically a dev mistake, but a business decision. For a small shop at the very beginning, when you're getting a handful of orders a week and the owner is literally watching the inbox, checkout that just sends an email can be a reasonable phase one. As long as there's a plan to outgrow it.

The problem is when it stays that way by accident. Volume picks up. You add payment. Someone asks "what did we charge this customer in March?" and the answer is "search Gmail." And of course, the biggest problem - email fails to send and the order is just gone. Fulfillment, refunds, disputes - all harder than they needed to be because the system celebrated success without writing anything down.

Email is a notification, not a checkout. The business eventually needs a system of record. Sooner rather than later, if you ask me. Before the debt creeps up.

When you do add it, don't forget to snapshot the price the customer paid at that time, not whatever the product costs after the next sale.

Also while you're there: disable the submit button while checkout is in flight. I've seen double-clicks send two confirmation emails before anyone added a payment gateway. Annoying at low volume; expensive once Stripe is wired up. Even on an email-only MVP, disabled={isPending} is cheap insurance.


4. Free shipping by accident

This one can be caused by one simple line:

1const shippingCost = selectedShippingMethod?.price ?? 0;

We're all used to writing fallbacks like this when we're using TypeScript. But here, zero is a financial decision, not a fallback.

If a shipping method API request fails for whatever reason, or the seller hasn't set up delivery methods in the CMS yet - you've just gifted the customer free shipping, since the shipping price falls back to 0 and the order goes through successfully - even though the seller doesn't have the ability to send the order for free.

Instead, missing or invalid shipping should fail with a clear error, not default to free (I recommend my favorite method - early return):

1if (!selectedShippingMethod) {
2 return Response.json(
3 { error: "Invalid or missing shipping method" },
4 { status: 400 },
5 )
6}
7const shippingCost = selectedShippingMethod.price

And when the shipping returns this error, block the order from being able to complete, with a clear message somewhere that says the shipping methods are not available at the moment or something.


5. When the same amount is different in different places

This one can manifest in different ways. The cart line says $200, the summary says $160 or vice versa... Somewhere the amount shows 26.07, but somewhere it's 26.069999999999998, and so on and so forth...

You can guess how this happens. Amounts appear in different places, but instead of the calculations being defined in only one place as they should, they are rewritten inline wherever the amounts are needed. So for example, you could calculate a product's total price in one place like this: discountedPrice * quantity, but then forget to account for the discount in another: price * quantity.

Most often, there will be a calculation defined in 5 different places, let's say a discount:

1const discountedPrice =
2 discountPercentage > 0
3 ? price - price * (discountPercentage / 100)
4 : price

And somewhere down the line there will be a task to update something in the calculation logic or the way it's displayed, or something.

In my experience, when you have the same thing defined in multiple places and you need to change something, it's almost guaranteed that at least one of those places will be forgotten - especially if we're talking about bigger teams and the person making the edit is not the person who initially wrote the code.

The solution?

Take these calculations and extract them into a set of nicely organized and documented pure functions and make sure that everything is done only in one place.

Trust me, I'm the first one to caution against too much and too early abstractions, but in this case, they are your friend.

For example, take the discountedPrice definition from earlier. That one could be rewritten like this:

1export function calculateDiscountedPrice(
2 price: number,
3 discountPercentage: number,
4): number {
5 if (discountPercentage <= 0) return roundMoney(price); // again, my favorite early return, personal preference
6 return roundMoney(price - price * (discountPercentage / 100));
7}

Of course, that means there should be a rounding function before that - something to smooth over most of the JavaScript floating point quirks:

1export function roundMoney(amount: number): number {
2 return Math.round(amount * 100) / 100;
3}

Then, you could have something for calculating a product total - like a cart line (price multiplied by quantity), and another for a subtotal (the sum of line totals):

1export function getLineTotal(line: CartLine): number {
2 const unit = calculateDiscountedPrice(line.price, line.discountPercentage)
3 return roundMoney(unit * line.quantity)
4}
5
6export function getCartSubtotal(lines: CartLine[]): number {
7 return roundMoney(lines.reduce((sum, line) => sum + getLineTotal(line), 0))
8 // - the sum of line totals, as in the sum of getLineTotal()s - not a second formula that "should" match
9}

And on top of that you could (in fact, should) have a currency formatter function that would turn the number into a currency string - where you could define things like a) whether you always want 2 decimals (so that 12.2 becomes "12.20") and b) strap a currency code on it, so that in the end you get "$12.20" or "12,20 RSD" or whatever you need.

**Important**: Note how all these functions fall nicely into the next one - roundMoney is used in calculateDiscountedPrice, calculateDiscountedPrice is used in getLineTotal, and getLineTotal is used in getCartSubtotal, etc.

That means that, when we need to change something related to how the price is calculated, we only do that in one place, and that will take care of every single place where calculateDiscountedPrice, and getLineTotal, and getCartSubtotal are used, and so on and so forth...

Not to mention that, if you're in a fullstack JS project, you can reuse these server side too, which should make fixing #1 easier :)


Bonus tip:

const body: CheckoutRequest = await request.json() is a type annotation, not validation.

This is a general rule, not just for ecommerce... The codebase won't know what actually gets returned in a request body, so TypeScript won't protect you from invalid fields here.

What I'd advise on doing, if you're already using a validation tool like zod, you can use that to validate the body when you're processing it, so that you can, in case of invalid fields - you guessed it - early return :)

1const parsed = orderSchema.safeParse(await request.json())
2if (!parsed.success) {
3 return Response.json({ error: "Invalid order" }, { status: 400 })
4}

Does this mean I was actually a closeted fan of list posts and just didn't want to admit it? I don't know, right now in my head I just have a mental image of Dr Mike shouting "chest compressions" all the time, but instead it's me, and I'm shouting "early return, early return, early return"! :D