Back to Blog
Energetic speaker presenting at a conference event with an engaged audience
Tutorial
August 13, 2026
10 min read

How to Build an Event Registration Page with a JavaScript Calendar

By SimpleCalendarJS Team

SimpleCalendarJS~18 KB gzipped · Zero dependencies · Any framework

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.

SolutionPer-Ticket FeesAnnual Cost (200 tickets/mo)Bundle ImpactBrandingData Ownership
Eventbrite3.7% + $1.79 + 2.9%~$12,000+ in feesExternal iframeEventbrite badgeEventbrite's servers
Luma (free)5% + Stripe fees~$7,200+ in feesExternal pageLuma badgeLuma's servers
Luma PlusStripe fees only$708 + Stripe feesExternal pageNoneLuma's servers
DHTMLX Event CalendarN/A (build yourself)$799+ license~100 KB+NoneYours
FullCalendarN/A (build yourself)$750+/yr (premium)~500 KBNoneYours
SimpleCalendarJSN/A (build yourself)Free personal / $49/yr commercial~14 KBNoneYours

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

FeatureEventbriteLumaCustom + SimpleCalendarJS
Setup timeMinutesMinutesHalf a day
Per-ticket fees3.7% + $1.79 + 2.9%5% + Stripe feesStripe only (2.9% + 30¢)
Calendar viewList onlyCalendar pageMonth, week, agenda
Category filtersTagsTagsCustom colours & toggles
Capacity enforcementBuilt-inBuilt-inYour logic (atomic DB)
WaitlistPaid plansBuilt-inYour logic
Custom brandingPaid plansPaid plansFull control
Bundle sizeExternal iframeExternal page~14 KB
Data ownershipEventbrite's serversLuma's serversYours

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 UPDATE locking — 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

Image Credits

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.