Web Dev13 min read · 1 April 2026

Building a Full-Stack Booking SaaS with React, Node.js & Prisma

How I built CanopyCare — a production booking system with a 4-step wizard, live slot calendar, budget-to-package quote engine, admin panel, JWT auth, Cloudinary uploads, and SendGrid emails — from scratch to live deployment.

ReactNode.jsPrismaPostgreSQLViteTailwindJWTZustand

What We're Building

CanopyCare is a full-stack SaaS booking platform for a canopy cleaning business. Customers can book cleaning slots online, state their budget, and receive an instant package quote. The business owner manages everything through a dedicated admin panel.

Live at: canopycare.contentforge.net

Tech Stack

LayerTechnology
FrontendReact 18, Vite, Tailwind CSS, Zustand
FormsReact Hook Form, Zod
BackendNode.js, Express
ORMPrisma
DatabasePostgreSQL (Supabase)
AuthJWT + bcryptjs
UploadsMulter + Cloudinary
EmailNodemailer + Resend

The 4-Step Booking Wizard

The core UX challenge was making a complex booking flow feel simple. I broke it into 4 clear steps:

  1. Cleaning details — canopy type, size, grease level, address, photo uploads
  2. Slot picker — live calendar showing available time slots
  3. Budget selector — instant package match via quote engine
  4. Review & confirm — full breakdown with cancellation policy
jsx
// BookingWizard.jsx
const STEPS = ['Details', 'Slot', 'Package', 'Confirm']

export default function BookingWizard() {
  const [step, setStep] = useState(0)
  const { booking, updateBooking } = useBookingStore()

  return (
    <div>
      <StepIndicator steps={STEPS} current={step} />
      {step === 0 && <CleaningDetailsForm onNext={() => setStep(1)} />}
      {step === 1 && <SlotPicker onNext={() => setStep(2)} />}
      {step === 2 && <QuoteCard onNext={() => setStep(3)} />}
      {step === 3 && <BookingConfirm onBack={() => setStep(2)} />}
    </div>
  )
}

The Quote Engine

The budget-to-package matching algorithm runs on both frontend and backend (shared logic):

javascript
// quoteEngine.js (shared between frontend and backend)
export function matchPackage(budget, packages) {
  // Sort packages by price ascending
  const sorted = [...packages].sort((a, b) => a.price - b.price)
  
  // Find the best package within budget
  const affordable = sorted.filter(p => p.price <= budget)
  
  if (affordable.length === 0) {
    // Return cheapest package with upgrade prompt
    return { package: sorted[0], upgrade: true }
  }
  
  // Return most expensive affordable package (best value)
  return { package: affordable[affordable.length - 1], upgrade: false }
}

JWT Authentication with Role-Based Access

javascript
// auth.middleware.js
export const authenticate = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1]
  if (!token) return res.status(401).json({ error: 'No token' })
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET)
    req.user = await prisma.user.findUnique({ where: { id: decoded.id } })
    next()
  } catch {
    res.status(401).json({ error: 'Invalid token' })
  }
}

// role.middleware.js
export const requireAdmin = (req, res, next) => {
  if (req.user.role !== 'ADMIN') {
    return res.status(403).json({ error: 'Admin only' })
  }
  next()
}

Cloudinary Photo Uploads

javascript
// upload.service.js
import { v2 as cloudinary } from 'cloudinary'
import { Readable } from 'stream'

export async function uploadToCloudinary(buffer, folder) {
  return new Promise((resolve, reject) => {
    const stream = cloudinary.uploader.upload_stream(
      { folder, resource_type: 'image' },
      (error, result) => {
        if (error) reject(error)
        else resolve(result.secure_url)
      }
    )
    Readable.from(buffer).pipe(stream)
  })
}

Preventing Double-Bookings

Race conditions are a real problem with slot booking. I used Prisma transactions:

javascript
// bookingUtils.js
export async function createBookingWithSlot(data) {
  return await prisma.$transaction(async (tx) => {
    // Lock the slot row
    const slot = await tx.slot.findUnique({
      where: { id: data.slotId }
    })
    
    if (slot.bookedCount >= slot.capacity) {
      throw new Error('Slot is fully booked')
    }
    
    // Create booking and increment slot count atomically
    const [booking] = await Promise.all([
      tx.booking.create({ data }),
      tx.slot.update({
        where: { id: data.slotId },
        data: { bookedCount: { increment: 1 } }
      })
    ])
    
    return booking
  })
}

24-Hour Cancellation Rule

javascript
export function canCancel(slotDateTime) {
  const hoursUntilSlot = differenceInHours(
    new Date(slotDateTime),
    new Date()
  )
  return hoursUntilSlot >= parseInt(process.env.CANCELLATION_HOURS || '24')
}

Deployment Stack

  • Database: Supabase (free PostgreSQL)
  • Backend: Render (free Node.js hosting)
  • Frontend: Vercel (free Vite/React hosting)
  • Domain: canopycare.contentforge.net (Cloudflare subdomain, free)
  • Photos: Cloudinary (free tier)
  • Email: Resend (free tier)

Total monthly cost: £0

The full source is split across two repos — canopycare-backend and canopycare-frontend on GitHub.

MH
Mahmudul Hassan Mithun
AI SaaS Builder · BSc Data Science & AI, UEL · Building ContentForge AI

Related Posts

Multi-Tenant SaaS Architecture with Next.js 15, Prisma & Auth0
Multi-Tenant SaaS Architecture with Next.js 15, Prisma & Auth0
15 min read →
Deploying a Full-Stack SaaS for Free: Supabase + Render + Vercel
Deploying a Full-Stack SaaS for Free: Supabase + Render + Vercel
9 min read →