Full-stack wellness professional marketplace. Users browse and book certified trainers, nutritionists, and physiotherapists. Payments split automatically 50/50 between platform and professional via Stripe Connect.
Live demo β trainmehard.vercel.app
TrainMeHard is a "one-shot" marketplace β users can browse profiles for free and unlock access to a professional by paying a single Initial Session fee. The platform takes 50% as commission; the professional receives the other 50% instantly via Stripe Connect's automatic transfer.
Three professional verticals share the same infrastructure, differentiated by professionalType:
| Vertical | Description |
|---|---|
| Fitness trainers | In-person or online sessions |
| Nutritionists | Meal planning and dietary consultations |
| Physiotherapists | Physical assessment and rehabilitation |
TrainMeHard/
βββ fitness-frontend/ # Next.js 15 β App Router, TypeScript
βββ fitness-backend/ # Node.js + Express 5 β REST API, TypeScript
The backend follows a strict layered pattern β nothing bleeds between layers:
Request β Route (validation) β Controller (orchestration) β Service (business logic) β Model (data)
The Stripe integration lives exclusively in payment.service and payout.service. Controllers never touch the Stripe SDK directly.
| Layer | Technologies |
|---|---|
| Frontend | Next.js 15 Β· React 19 Β· TypeScript Β· Tailwind CSS Β· shadcn/ui Β· Zustand Β· React Hook Form Β· Axios |
| Backend | Node.js 20 Β· Express 5 Β· TypeScript Β· MongoDB (Mongoose) Β· Zod Β· Stripe Connect SDK |
| Auth | JWT (access + refresh) Β· httpOnly cookies Β· refresh token rotation |
| Security | Helmet Β· express-rate-limit Β· mongo-sanitize Β· CORS whitelist Β· Zod (backend re-validation) |
| Logging | Winston (structured) Β· Morgan (HTTP) |
| Testing | Vitest Β· Supertest |
| Deploy | Vercel (frontend) Β· Render (API) Β· MongoDB Atlas |
The most complex piece. When a user pays for a session:
User triggers checkout
β
βΌ
PaymentIntent created in backend (amount determined server-side only)
β
βΌ
Stripe processes charge
β
ββββΊ 50% transferred to professional's Connect account (automatic)
ββββΊ 50% retained as application_fee_amount (platform)
β
βΌ
Stripe webhook confirms payment (idempotent)
β
βΌ
Session marked as `paid` in MongoDB β stripePaymentIntentId stored for audit
The webhook is the source of truth β the browser redirect is never trusted.
- Short-lived access tokens (15 min) in
Authorizationheader - Long-lived refresh tokens (7 days) in
httpOnly; Secure; SameSite=Strictcookie - Refresh token rotation on every renewal β previous token invalidated immediately (stolen token detection)
| Role | Capabilities |
|---|---|
super_admin |
Full access, admin management |
admin |
Moderation panel, dispute resolution, metrics |
professional |
Profile CRUD, availability, payout tracking |
user |
Search, book, pay, review |
Role is embedded in the JWT payload. A requireRole(...roles) middleware composes on routes:
router.delete('/users/:id', requireAuth, requireRole('admin', 'super_admin'), deleteUser);After onboarding, a professional's profile is isApproved: false. An admin reviews and approves before the profile is publicly discoverable. Prevents fraudulent listings.
Three separate dashboard experiences:
- User β session history, booking status, reviews left
- Professional β profile management, availability calendar, payout history, onboarding wizard
- Admin β professional approval queue, dispute resolution, platform metrics
| Measure | Tool | Detail |
|---|---|---|
| HTTP headers | helmet |
XSS, clickjacking, MIME sniffing protection |
| Rate limiting | express-rate-limit |
100 req / 15 min per IP on public routes |
| NoSQL injection | mongo-sanitize + zod |
Input sanitization before any DB write |
| CORS | cors |
Explicit origin whitelist only |
| Data validation | zod |
Backend always re-validates β never trusts client |
| Prices | Backend only | amount is calculated server-side; front end never sends a price |
| Error exposure | errorHandler middleware |
Stack traces never reach the client in production |
| Decision | Why |
|---|---|
| Stateless JWT over server sessions | Future mobile app compatibility |
| Stripe Connect over manual transfers | Automatic split, regulatory compliance, no payout logic to maintain |
| MongoDB over PostgreSQL | Flexible schema across 3 professional verticals with different field sets |
| Zustand over Redux | 80% less boilerplate for this project scope |
| Shared Zod schemas (front + back) | Single source of truth β if the schema changes, both sides break at compile time |
| Express 5 over Fastify | Better ecosystem fit; native async/await error propagation in v5 |
Every endpoint returns the same envelope β no guessing the shape:
// Success
{ "success": true, "data": { ... } }
// Error
{ "success": false, "error": { "code": "PROFESSIONAL_NOT_FOUND", "message": "...", "statusCode": 404 } }Error codes in SCREAMING_SNAKE_CASE for machine-readable handling.
Backend
cd fitness-backend
npm install
cp .env.example .env # fill in MONGODB_URI, JWT secrets, STRIPE keys
npm run dev # http://localhost:3001Frontend
cd fitness-frontend
npm install
npm run dev # http://localhost:3000Run tests
cd fitness-backend && npm test
cd fitness-frontend && npm test# App
NODE_ENV=development
PORT=3001
FRONTEND_URL=http://localhost:3000
# MongoDB
MONGODB_URI=mongodb+srv://...
# JWT
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PUBLISHABLE_KEY=
# Optional
CLOUDINARY_URL=
RESEND_API_KEY=
SENTRY_DSN=