Back to Blog
Woman writing in a calendar planner, scheduling appointments and marking important dates
Tutorial
August 10, 2026
10 min read

How to Build an Appointment Booking Widget for Your Website

By SimpleCalendarJS Team

SimpleCalendarJS~18 KB gzipped · Zero dependencies · Any framework

Every business website — from dental clinics to consulting firms to hair salons — eventually needs an appointment booking widget. Visitors pick a service, choose a date and time, and reserve a slot. The obvious solution is Calendly or Cal.com, but those come with monthly fees, third-party branding, and zero control over the experience. Building your own appointment booking widget is simpler than it looks, and the calendar UI is the only hard part.

The third-party widget trap

Before building anything, understand what you're replacing and why.

Calendly is the default choice for appointment scheduling. The free plan gives you one event type with Calendly branding on every page. Remove the branding or add a second event type, and you're paying $12/seat/month. For a five-person team, that's $720/year — recurring, forever. And your booking data lives on Calendly's servers, not yours.

Cal.com is the open-source alternative. Self-hosting is free (AGPL license), but you need to maintain the Docker deployment, handle updates, and manage the infrastructure. The hosted version starts at $12/user/month — same price as Calendly. The embed widget supports inline, pop-up, and floating button modes, which is convenient but still ties your booking flow to their platform.

DHTMLX Booking is a dedicated JavaScript booking widget with provider cards, time slot selection, and a configurable form. The widget itself is a $29 add-on, but it requires a DHTMLX Scheduler license starting at $799/year for an individual developer. The bundle adds ~100 KB+ to your page.

SolutionCostBundle ImpactBrandingData Ownership
CalendlyFree (1 event) / $12+/seat/moExternal iframeCalendly badge on free tierCalendly's servers
Cal.com (hosted)$12+/user/moExternal iframeCal.com badge on free tierCal.com's servers
Cal.com (self-hosted)Free (AGPL)Full app deploymentNoneYours
DHTMLX Booking$29 + $799+/yr (Scheduler)~100 KB+NoneYours
Mobiscroll$395+/project~80 KB+NoneYours
SimpleCalendarJSFree personal / $49/yr commercial~14 KBNoneYours

The pattern is clear: third-party platforms trade control for convenience, and commercial widgets charge enterprise prices for what is fundamentally a calendar plus a form. If your booking flow is straightforward — pick a service, pick a date, pick a time, confirm — you can build the widget yourself with a lightweight calendar library and own every pixel.

Building the appointment booking widget

The widget has four steps from the user's perspective: select a service, pick a date, choose a time slot, and confirm the booking. Here's how to build each one.

Step 1: Set up the calendar

npm install simple-calendar-js
import SimpleCalendarJs from 'simple-calendar-js'; import 'simple-calendar-js/dist/simple-calendar-js.min.css'; const calendar = new SimpleCalendarJs('#booking-widget-calendar', { defaultView: 'month', locale: 'en-US', enabledViews: ['month'], fetchEvents: async (start, end) => { // Show days with available slots in a different colour const res = await fetch( `/api/availability?from=${start.toISOString()}&to=${end.toISOString()}&service=${selectedService}` ); const days = await res.json(); return days .filter((d) => d.availableSlots > 0) .map((d) => ({ id: `avail-${d.date}`, title: `${d.availableSlots} slots`, start: new Date(d.date), end: new Date(d.date), color: '#10b98140', })); }, onSlotClick: (date) => { loadTimeSlots(date); }, });

This renders a month calendar at ~14 KB gzipped with zero dependencies. Days with available appointment slots are highlighted green, and clicking a day triggers the time slot selector. No framework required — it works in React, Vue, Angular, Svelte, or plain HTML.

Step 2: Build the service selector

Before the calendar, let the user choose what they're booking. This is standard DOM manipulation — no library needed:

const services = [ { id: 'consultation', name: '30-min Consultation', duration: 30 }, { id: 'deep-dive', name: '60-min Deep Dive', duration: 60 }, { id: 'follow-up', name: '15-min Follow-up', duration: 15 }, ]; let selectedService = null; function renderServiceSelector(container) { const list = document.createElement('div'); list.className = 'service-list'; services.forEach((service) => { const btn = document.createElement('button'); btn.textContent = `${service.name} (${service.duration} min)`; btn.className = 'service-btn'; btn.addEventListener('click', () => { selectedService = service; document.querySelectorAll('.service-btn') .forEach((b) => b.classList.remove('active')); btn.classList.add('active'); calendar.refetchEvents(); // Refresh availability for this service }); list.appendChild(btn); }); container.appendChild(list); }

Step 3: Load and display time slots

When the user clicks a day on the calendar, fetch available time slots from your API and render them:

async function loadTimeSlots(date) { const dateStr = date.toISOString().split('T')[0]; const res = await fetch( `/api/slots?date=${dateStr}&service=${selectedService.id}` ); const slots = await res.json(); const panel = document.getElementById('time-slots'); panel.innerHTML = ''; if (slots.length === 0) { panel.innerHTML = '<p class="no-slots">No available times on this date.</p>'; return; } slots.forEach((slot) => { const btn = document.createElement('button'); btn.textContent = slot.time; // e.g. "10:00 AM" btn.className = 'time-slot-btn'; btn.addEventListener('click', () => openConfirmationForm(date, slot)); panel.appendChild(btn); }); }

Step 4: Handle the booking confirmation

async function submitAppointment(service, date, slot, formData) { const res = await fetch('/api/appointments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serviceId: service.id, date: date.toISOString().split('T')[0], time: slot.time, duration: service.duration, name: formData.name, email: formData.email, phone: formData.phone, notes: formData.notes, }), }); if (res.status === 409) { showError('This slot was just booked. Please choose another time.'); loadTimeSlots(date); // Refresh available slots return; } const appointment = await res.json(); showConfirmation(appointment); calendar.refetchEvents(); }

The 409 Conflict handler is essential. Two visitors might click the same 10:00 AM slot at the same time — the server accepts the first request and rejects the second. The widget refreshes the slot list so the second user sees updated availability immediately.

Making the widget embeddable

The real power of a custom widget is embedding it on any page — your marketing site, a client's WordPress blog, or a standalone landing page. Wrap everything in a self-initialising script:

(function () { const WIDGET_ID = 'appointment-booking-widget'; // Don't initialise twice if (document.getElementById(WIDGET_ID)) return; // Create container const container = document.createElement('div'); container.id = WIDGET_ID; document.currentScript.parentNode.insertBefore( container, document.currentScript ); // Load styles const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://your-cdn.com/booking-widget.css'; document.head.appendChild(link); // Initialise the widget initBookingWidget(container, { apiBase: 'https://your-api.com', services: ['consultation', 'deep-dive', 'follow-up'], theme: 'light', }); })();

Site owners paste one line before their closing </body> tag:

<script src="https://your-cdn.com/booking-widget.js"></script>

The total payload: ~14 KB for SimpleCalendarJS plus your widget logic — typically under 25 KB gzipped total. Compare that to loading a Calendly iframe (which pulls in their entire React app) or a DHTMLX bundle at 100 KB+.

Styling the widget to match any site

SimpleCalendarJS uses CSS custom properties, so the widget adapts to its host page with a few overrides:

#appointment-booking-widget { --uc-primary: #2563eb; --uc-bg: #ffffff; --uc-border: #e5e7eb; --uc-text: #111827; font-family: inherit; max-width: 480px; } @media (prefers-color-scheme: dark) { #appointment-booking-widget { --uc-primary: #60a5fa; --uc-bg: #1f2937; --uc-border: #374151; --uc-text: #f9fafb; } } .service-btn.active { background: var(--uc-primary); color: #fff; } .time-slot-btn { padding: 8px 16px; border: 1px solid var(--uc-border); border-radius: 6px; cursor: pointer; transition: background 0.15s; } .time-slot-btn:hover { background: var(--uc-primary); color: #fff; }

Because the widget uses scoped CSS and a unique container ID, it won't clash with the host page's styles. No Shadow DOM complexity, no iframe isolation — just clean selectors.

Feature comparison: SaaS vs custom widget

FeatureCalendly / Cal.comDHTMLX BookingCustom + SimpleCalendarJS
Setup timeMinutesHoursHalf a day
Monthly cost$12+/seat$0 (after license)$0
Upfront cost$0$799+Free personal / $49/yr commercial
Bundle sizeExternal iframe~100 KB+~25 KB total
Custom brandingPaid plans onlyFullFull
Custom booking flowLimitedWidget APIUnlimited
Data ownershipThird-partyYoursYours
Email notificationsBuilt-inBuild yourselfBuild yourself
Multi-service supportYes (paid)ConfigurableYour logic

The SaaS option wins when you need something running in five minutes and don't care about branding or data ownership. The custom widget wins everywhere else — especially when you're embedding on client sites, need pixel-perfect design control, or want to avoid per-seat recurring costs that scale with your team.

Extending the widget

Once the core booking flow works, add features incrementally:

Timezone-aware slots

function formatSlotTime(utcTime, userTimezone) { return new Intl.DateTimeFormat('en-US', { timeZone: userTimezone, hour: 'numeric', minute: '2-digit', hour12: true, }).format(new Date(utcTime)); } // Detect the user's timezone automatically const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;

Confirmation emails

Wire the booking API to a transactional email service. After a successful insert, send a confirmation with the appointment details:

// Server-side (Node.js example) async function handleBooking(req, res) { const booking = await insertBooking(req.body); if (!booking) { return res.status(409).json({ error: 'Slot no longer available' }); } await sendEmail({ to: booking.email, subject: `Appointment Confirmed: ${booking.serviceName}`, template: 'booking-confirmation', data: { name: booking.name, service: booking.serviceName, date: booking.date, time: booking.time, }, }); return res.status(201).json(booking); }

Cancellation and rescheduling

Include a unique token in the confirmation email that lets users cancel or reschedule without logging in:

async function cancelAppointment(token) { const res = await fetch(`/api/appointments/cancel?token=${token}`, { method: 'POST', }); return res.ok; }

Summary

  • Third-party booking widgets (Calendly, Cal.com) charge $12+/seat/month and load external iframes you can't fully control or brand
  • Commercial JavaScript booking widgets (DHTMLX Booking, Mobiscroll) cost $395–$799+ upfront and add 80–100 KB+ to your bundle
  • A custom appointment booking widget built with SimpleCalendarJS ships at ~25 KB total (calendar + widget logic) with full design control and zero recurring fees
  • The widget is four steps: service selection → date picking → time slot choice → confirmation — each is standard JavaScript with no framework required
  • Make it embeddable with a self-initialising script and scoped CSS — site owners paste one <script> tag and the widget renders anywhere
  • Double-booking prevention happens server-side — the widget handles 409 Conflict responses by refreshing available slots

Sources & Further Reading

Research & References

Image Credits

All images free to use under the Pexels License.

Frequently Asked Questions

What is the cheapest way to add an appointment booking widget to my website?

Calendly's free plan gives you one event type with Calendly branding. Cal.com's self-hosted edition is free but requires server infrastructure. For a fully custom widget with no branding or recurring fees, use SimpleCalendarJS (~14 KB, free for personal use) as the calendar UI and connect it to your own backend — total infrastructure cost is just your existing hosting.

Can I build a booking widget without a backend?

You can build the front-end widget without a backend, but you'll need a server to store bookings and prevent double-booking. A lightweight option is a serverless function (AWS Lambda, Vercel Edge Functions, Cloudflare Workers) connected to a database. The calendar UI itself is entirely client-side.

How do I embed a booking widget on any website?

Wrap your widget code in a self-initialising script that creates its own DOM container. Host the JavaScript and CSS on a CDN, then give site owners a single script tag to paste before their closing body tag. The widget renders inside an isolated container and doesn't interfere with the host page's styles.

Is Calendly free for appointment booking?

Calendly offers a free plan, but it's limited to one active event type, one calendar connection, and displays Calendly branding. The Standard plan ($12/seat/month) unlocks multiple event types and removes branding. For teams, pricing goes up to $20/seat/month or custom enterprise rates.

What's the difference between a booking system and a booking widget?

A booking system is the full stack — calendar UI, availability logic, database, conflict prevention, and notifications. A booking widget is the front-end component users interact with: they pick a service, choose a date and time, and submit. The widget talks to your booking system's API but doesn't contain business logic itself.

How do I prevent double bookings in a JavaScript appointment widget?

Double-booking prevention must happen server-side, not in the widget. Use an atomic database operation that checks availability and inserts the booking in a single query. The widget should handle 409 Conflict responses gracefully by refreshing available slots and prompting the user to pick another time.

Add a calendar to your app today

Free for personal projects. $49/year or $199 lifetime per commercial project.