AMSAhub — Freelance Services Marketplace

React.jsNode.jsExpressMongoDBStripeREST API· 3 min read

A marketplace for freelance services where the same platform serves two opposite intentions: someone selling a skill and someone buying one. Both sides browse the same listings, exchange messages on the same threads, and read the same ratings — but almost never want to see them the same way.

The problem & constraints

A two-sided marketplace's hard part isn't the CRUD, it's that buyer and seller need different answers from identical data, and that money is involved. Two constraints followed from that: the role a request acts under can never come from the client, and neither can the amount charged. A third came from the listing pages — a grid of service cards showing star ratings can't afford to aggregate a reviews collection on every render.

Approach

  • One User model carrying an isSeller flag, embedded in the JWT payload at login. getOrders then filters on the role read from the token — one endpoint, two correct answers, and no client-supplied role parameter to forge.
  • The JWT lives in an httpOnly cookie rather than localStorage, so browser-side script can't read it.
  • Stripe PaymentIntent created server-side, with the amount read from the stored listing record — the browser receives only the clientSecret and never states a price.
  • Ratings denormalized onto the listing as a running totalStars + starNumber pair, so a card renders its average from two integers it already has.
  • Conversations and messages modeled as separate collections, keeping the thread list cheap to load independently of the messages inside it.
client (React)httpOnly JWT cookieExpress APIrole read from JWTamount from listingMongoDBlistings · orders ·reviews · messagesStripePaymentIntent

Described in full:

  1. client (React) — httpOnly JWT cookie → Express API — role read from JWT — amount from listing
  2. Express API — role read from JWT — amount from listing → MongoDB — listings · orders · — reviews · messages
  3. Express API — role read from JWT — amount from listing → Stripe — PaymentIntent
One boundary, two decisions the client never makes: the role a request acts under is read from the JWT, and the amount charged is read from the stored listing — the browser only ever receives the clientSecret.

Trade-offs made

Decision

A denormalized rating on the listing over aggregating reviews at read time: two integers on the document a card already fetches, instead of a join per card on a grid — accepting that the pair must be updated transactionally with each new review, and that a deleted review needs explicit correction rather than simply disappearing from a recount.

Decision

Role carried in the token rather than re-read from the database per request: removes a lookup from every authenticated call, at the cost of the role being fixed for the token's lifetime — a seller who changes status stays on the old view until the token is reissued.

The first of those two trade-offs carries its cost inside a subordinate clause: a deleted review has to be corrected for explicitly. Here is that pair, live — add reviews and it stays honest, delete one and watch it stop:

A service listing4.08 (12)what a grid card renders
Stored on the listing documenttotalStars 49 · starNumber 124.08
Recounted from the reviews collection12 reviews4.08

The pair agrees with the collection — every insert updated both at once.

Add a review

Documents read to render that grid: 24 with the stored pair · 312 recounting per card

Demonstration of the data model, computed live in your browser — the same two-integer update the marketplace performs. Not real listings, not real reviews, and not measured query times.

Results

A working marketplace loop: publish a listing, message about it, pay through Stripe, and leave a review — with the authorization and pricing decisions made server-side rather than trusted to the client.

What I'd do differently

Move order creation to a Stripe webhook. As it stands the order row is written when the PaymentIntent is created, before payment confirms, so an abandoned checkout leaves a persisted order that only the isCompleted filter hides — the data is honest but the collection isn't clean, and the payment provider's own callback is what should be authoritative. I'd also lift the hardcoded usd currency into configuration.