How to Build an Appointment Booking Widget for Your Website
By SimpleCalendarJS Team
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.
| Solution | Cost | Bundle Impact | Branding | Data Ownership |
|---|---|---|---|---|
| Calendly | Free (1 event) / $12+/seat/mo | External iframe | Calendly badge on free tier | Calendly's servers |
| Cal.com (hosted) | $12+/user/mo | External iframe | Cal.com badge on free tier | Cal.com's servers |
| Cal.com (self-hosted) | Free (AGPL) | Full app deployment | None | Yours |
| DHTMLX Booking | $29 + $799+/yr (Scheduler) | ~100 KB+ | None | Yours |
| Mobiscroll | $395+/project | ~80 KB+ | None | Yours |
| SimpleCalendarJS | Free personal / $49/yr commercial | ~14 KB | None | Yours |
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
| Feature | Calendly / Cal.com | DHTMLX Booking | Custom + SimpleCalendarJS |
|---|---|---|---|
| Setup time | Minutes | Hours | Half a day |
| Monthly cost | $12+/seat | $0 (after license) | $0 |
| Upfront cost | $0 | $799+ | Free personal / $49/yr commercial |
| Bundle size | External iframe | ~100 KB+ | ~25 KB total |
| Custom branding | Paid plans only | Full | Full |
| Custom booking flow | Limited | Widget API | Unlimited |
| Data ownership | Third-party | Yours | Yours |
| Email notifications | Built-in | Build yourself | Build yourself |
| Multi-service support | Yes (paid) | Configurable | Your 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 Conflictresponses by refreshing available slots
Sources & Further Reading
Research & References
- Calendly Pricing 2026: Plans, Features & Hidden Costs — Cal.com Blog
- Calendly Free Plan 2026: Features, Limitations & Best Alternatives — meetergo.com
- Calendly Embed Options Overview — Calendly Help
- 5 Best Embed Scheduling Widget Solutions — Cal.com Blog
- DHTMLX Booking 1.0: New Highly Configurable JS Booking Widget — dhtmlx.com
- DHTMLX Booking Widget for Handy Time Reservation — dhtmlx.com
- Top JavaScript Schedulers for Booking & Doctor Appointment Systems — DHTMLX Blog
- Best JavaScript Scheduler in 2026 — rv-grid.com
- How to Embed a Booking Widget on Your Website — usecarly.com
- I Tested 9 Booking Widgets for Websites (2026) — wisernotify.com
Image Credits
- Cover: Crop Woman Making Schedule in Calendar — Pexels
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.
