How to Build an Event Registration Page with a JavaScript Calendar
By SimpleCalendarJS Team
Every organisation that runs events — workshops, meetups, conferences, webinars — needs an event registration page with a calendar. Visitors should see upcoming events at a glance, click one, and register in seconds. The default path is Eventbrite or Luma, but those platforms charge per-ticket fees that compound fast and give you limited control over the experience. Building your own event registration page is simpler than it looks, and the calendar UI is the only technically demanding piece.
The platform fee problem
Before writing code, understand what you're replacing and what it costs.
Eventbrite charges 3.7% + $1.79 per paid ticket as a service fee, plus 2.9% payment processing. On a $50 workshop ticket, that's $5.09 in fees — over 10% gone before you see a dollar. Scale that to 200 attendees across 10 events per year and you're handing over $10,000+ annually. Even free events get the Eventbrite branding and a checkout flow you can't customise.
Luma is free to use, but takes a 5% platform fee on ticket sales (on top of Stripe's 2.9% + 30¢). A $50 ticket loses $3.95 in fees — better than Eventbrite, but still meaningful at volume. Removing the platform fee requires Luma Plus at $59/month ($708/year). And your attendee data lives on Luma's servers.
DHTMLX Event Calendar is a dedicated JavaScript calendar component with day, week, month, and agenda views. Licenses start at $799/year for an individual developer. The bundle adds ~100 KB+ to your page. It's a solid component, but it's a calendar — not a registration system. You still build the forms, capacity logic, and payment flow yourself.
| Solution | Per-Ticket Fees | Annual Cost (200 tickets/mo) | Bundle Impact | Branding | Data Ownership |
|---|---|---|---|---|---|
| Eventbrite | 3.7% + $1.79 + 2.9% | ~$12,000+ in fees | External iframe | Eventbrite badge | Eventbrite's servers |
| Luma (free) | 5% + Stripe fees | ~$7,200+ in fees | External page | Luma badge | Luma's servers |
| Luma Plus | Stripe fees only | $708 + Stripe fees | External page | None | Luma's servers |
| DHTMLX Event Calendar | N/A (build yourself) | $799+ license | ~100 KB+ | None | Yours |
| FullCalendar | N/A (build yourself) | $750+/yr (premium) | ~500 KB | None | Yours |
| SimpleCalendarJS | N/A (build yourself) | Free personal / $49/yr commercial | ~14 KB | None | Yours |
The pattern: SaaS platforms charge per-ticket fees that scale with your success. Enterprise calendar libraries charge hundreds upfront and ship heavy bundles. If your event registration needs are standard — display events, collect signups, enforce capacity — you can build it yourself with a lightweight calendar and own the entire experience.
Building the event registration page
The page needs three capabilities: display events on a calendar, show event details with a registration form, and enforce capacity limits. Here's how to build each one.
Step 1: Render events on 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('#event-calendar', { defaultView: 'month', locale: 'en-US', enabledViews: ['month', 'week'], fetchEvents: async (start, end) => { const res = await fetch( `/api/events?from=${start.toISOString()}&to=${end.toISOString()}` ); const events = await res.json(); return events.map((event) => ({ id: event.id, title: event.name, start: new Date(event.startDate), end: new Date(event.endDate), color: event.spotsRemaining > 0 ? '#10b98140' : '#ef444440', })); }, onEventClick: (event) => { openRegistrationPanel(event.id); }, });
This renders a month calendar at ~14 KB gzipped with zero dependencies. Events with available spots show green; sold-out events show red. Clicking any event opens the registration panel. The fetchEvents callback fires whenever the user navigates to a new month or week, so the calendar always shows current availability.
Step 2: Build the event detail and registration panel
When a user clicks an event on the calendar, fetch the full event details and render a registration form:
async function openRegistrationPanel(eventId) { const res = await fetch(`/api/events/${eventId}`); const event = await res.json(); const panel = document.getElementById('registration-panel'); panel.innerHTML = ` <h2>${event.name}</h2> <p class="event-meta"> ${formatDate(event.startDate)} · ${event.location} </p> <p class="event-description">${event.description}</p> <div class="capacity-badge ${event.spotsRemaining === 0 ? 'full' : ''}"> ${event.spotsRemaining > 0 ? `${event.spotsRemaining} of ${event.capacity} spots remaining` : 'Event is full'} </div> ${event.spotsRemaining > 0 ? renderRegistrationForm(event) : renderWaitlistForm(event)} `; } function renderRegistrationForm(event) { return ` <form id="reg-form" data-event-id="${event.id}"> <input type="text" name="name" placeholder="Full name" required /> <input type="email" name="email" placeholder="Email address" required /> ${event.price > 0 ? `<p class="price">Ticket: $${event.price}</p>` : ''} <button type="submit"> ${event.price > 0 ? `Register & Pay $${event.price}` : 'Register — Free'} </button> </form> `; }
Step 3: Handle registration submission
document.addEventListener('submit', async (e) => { if (e.target.id !== 'reg-form') return; e.preventDefault(); const form = e.target; const eventId = form.dataset.eventId; const formData = new FormData(form); const res = await fetch('/api/registrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ eventId, name: formData.get('name'), email: formData.get('email'), }), }); if (res.status === 409) { showError('This event just filled up. You have been added to the waitlist.'); openRegistrationPanel(eventId); return; } if (res.status === 400) { const error = await res.json(); showError(error.message); return; } const registration = await res.json(); showConfirmation(registration); calendar.refetchEvents(); });
The 409 Conflict handler is critical. Two visitors might click "Register" at the same time when only one spot remains. The server accepts the first and rejects the second. The panel refreshes to show the event is full, and the second visitor gets offered the waitlist instead.
Step 4: Enforce capacity server-side
Client-side capacity checks are for UX — the real enforcement happens in your API. Use an atomic database operation:
// Server-side (Node.js) async function createRegistration(eventId, name, email) { return await db.transaction(async (tx) => { const event = await tx.query( 'SELECT id, capacity, registered_count FROM events WHERE id = $1 FOR UPDATE', [eventId] ); if (event.registered_count >= event.capacity) { throw new ConflictError('Event is full'); } const registration = await tx.query( 'INSERT INTO registrations (event_id, name, email) VALUES ($1, $2, $3) RETURNING *', [eventId, name, email] ); await tx.query( 'UPDATE events SET registered_count = registered_count + 1 WHERE id = $1', [eventId] ); return registration; }); }
The FOR UPDATE lock prevents two concurrent requests from both reading "1 spot remaining" and both inserting. One gets the spot; the other gets a 409. This is the same pattern booking systems, ticketing platforms, and airline reservation systems use.
Adding paid event support
For paid events, integrate Stripe Checkout after the registration is created:
async function handlePaidRegistration(eventId, name, email, priceInCents) { const registration = await createRegistration(eventId, name, email); const session = await stripe.checkout.sessions.create({ line_items: [{ price_data: { currency: 'usd', product_data: { name: registration.eventName }, unit_amount: priceInCents, }, quantity: 1, }], mode: 'payment', success_url: `${BASE_URL}/events/${eventId}/confirmation?reg=${registration.id}`, cancel_url: `${BASE_URL}/events/${eventId}`, metadata: { registrationId: registration.id }, }); return { checkoutUrl: session.url }; }
Stripe charges 2.9% + 30¢ per transaction — no platform fee on top. On a $50 ticket, that's $1.75 total, compared to $5.09 on Eventbrite or $3.95 on Luma's free plan. At 200 tickets per month, the annual savings are significant.
Multi-event calendar with category filters
Most organisations run different types of events. Add category filters so visitors can find what they're looking for:
const CATEGORIES = [ { id: 'workshop', label: 'Workshops', color: '#8b5cf6' }, { id: 'meetup', label: 'Meetups', color: '#10b981' }, { id: 'conference', label: 'Conferences', color: '#f59e0b' }, { id: 'webinar', label: 'Webinars', color: '#3b82f6' }, ]; let activeCategories = CATEGORIES.map((c) => c.id); function toggleCategory(categoryId) { if (activeCategories.includes(categoryId)) { activeCategories = activeCategories.filter((c) => c !== categoryId); } else { activeCategories.push(categoryId); } calendar.refetchEvents(); } // In fetchEvents, filter by active categories fetchEvents: async (start, end) => { const params = new URLSearchParams({ from: start.toISOString(), to: end.toISOString(), categories: activeCategories.join(','), }); const res = await fetch(`/api/events?${params}`); const events = await res.json(); return events.map((event) => { const cat = CATEGORIES.find((c) => c.id === event.category); return { id: event.id, title: event.name, start: new Date(event.startDate), end: new Date(event.endDate), color: `${cat?.color || '#6b7280'}40`, }; }); },
Each category gets a distinct colour on the calendar — purple for workshops, green for meetups, amber for conferences, blue for webinars. Visitors toggle categories on or off to narrow down the view. The filtering happens server-side so you only transfer the events the user wants to see.
Feature comparison
| Feature | Eventbrite | Luma | Custom + SimpleCalendarJS |
|---|---|---|---|
| Setup time | Minutes | Minutes | Half a day |
| Per-ticket fees | 3.7% + $1.79 + 2.9% | 5% + Stripe fees | Stripe only (2.9% + 30¢) |
| Calendar view | List only | Calendar page | Month, week, agenda |
| Category filters | Tags | Tags | Custom colours & toggles |
| Capacity enforcement | Built-in | Built-in | Your logic (atomic DB) |
| Waitlist | Paid plans | Built-in | Your logic |
| Custom branding | Paid plans | Paid plans | Full control |
| Bundle size | External iframe | External page | ~14 KB |
| Data ownership | Eventbrite's servers | Luma's servers | Yours |
Eventbrite and Luma win when you need something running in five minutes and don't mind the fees or branding. The custom approach wins when you're running events regularly, want to keep per-ticket costs to Stripe's processing fee only, and need the calendar embedded directly in your site rather than linking out to a third-party page.
Summary
- Eventbrite charges 3.7% + $1.79 + 2.9% per ticket — on a $50 ticket, that's $5.09 in fees (over 10%). At scale, fees exceed $10,000/year
- Luma takes a 5% platform fee on its free plan; removing it costs $59/month (Luma Plus)
- A custom event registration page built with SimpleCalendarJS ships at ~14 KB, displays events on a real calendar with colour-coded categories, and limits payment fees to Stripe's 2.9% + 30¢
- Capacity enforcement uses an atomic database transaction with
FOR UPDATElocking — the same pattern used by ticketing platforms and airline reservation systems - The calendar supports category filters, month and week views, and live capacity badges — all in vanilla JavaScript with no framework required
Sources & Further Reading
Research & References
- Eventbrite Fees 2026: Full Breakdown for Organizers — Checkout Page
- Eventbrite Pricing 2026: Fees, Refunds & What's Actually Changed — SimpleTix
- How Much Does Eventbrite Cost in 2026? Real Math on a $20 Ticket — SimpleTix
- Luma Pricing (July 2026): Compare Plans & Find the Best One — SaaSworthy
- Luma vs Eventbrite — Luma Help
- Best Free Event Management Platforms 2026 — Calen.events
- DHTMLX JavaScript Event Calendar — dhtmlx.com
- Top JavaScript Scheduler Libraries in 2026 — jqwidgets.com
- Best JavaScript Scheduler in 2026 — rv-grid.com
- FullCalendar — JavaScript Event Calendar
Image Credits
- Cover: Energetic Speaker Presenting at a Conference — Pexels
All images free to use under the Pexels License.
Frequently Asked Questions
What is the best way to add event registration to a website?
It depends on your volume and budget. Eventbrite charges 3.7% + $1.79 per ticket plus 2.9% payment processing. Luma takes a 5% platform fee on its free plan. For full control and no per-ticket fees, build your own registration page with a lightweight JavaScript calendar like SimpleCalendarJS (~14 KB) and connect it to your own backend and payment processor.
How much does Eventbrite charge per ticket in 2026?
Eventbrite charges a 3.7% + $1.79 service fee per paid ticket, plus 2.9% payment processing. On a $20 ticket, total fees are about $3.11 (15.5%). On a $50 ticket, fees are about $5.09 (10.2%). These fees apply regardless of whether you pay for Eventbrite's Pro subscription.
Can I build an event registration page without a backend?
You can build the front-end calendar and registration form without a backend, but you need a server to store registrations, enforce capacity limits, and process payments. A lightweight option is a serverless function (Vercel Edge Functions, AWS Lambda, Cloudflare Workers) connected to a database like Supabase or PlanetScale.
How do I show events on a calendar with registration links?
Use a JavaScript calendar library to render events as coloured blocks on a month or week view. When a user clicks an event, display a detail panel with the event description, remaining capacity, and a registration form. SimpleCalendarJS supports this via the onEventClick callback and async fetchEvents for loading events from your API.
Is Luma free for event registration?
Luma is free for unlimited events and guests, but it takes a 5% platform fee on ticket sales (on top of Stripe's 2.9% + 30¢ processing fee). Luma Plus at $59/month (billed annually) removes the platform fee. Free events with no ticket sales cost nothing on any plan.
How do I prevent overbooking on an event registration page?
Capacity enforcement must happen server-side with an atomic database operation — check remaining spots and insert the registration in a single transaction. The front-end should show live capacity counts and handle 409 Conflict responses by refreshing the event details and informing the user the event is full.
Add a calendar to your app today
Free for personal projects. $49/year or $199 lifetime per commercial project.
