JavaScript Event Calendar: Load, Display and Filter Events
By SimpleCalendarJS Team
To build a JavaScript event calendar, have an API return events that overlap a date range. Map each one to an object with id, title, start and end, then let a calendar component fetch the range currently on screen. FullCalendar, EventCalendar, DayPilot Lite and SimpleCalendarJS all work this way.
A JavaScript event calendar sounds easy until you build one: events come from an API, some last several days, some repeat every week, and they all have to land on the right day in every visitor's time zone. This guide walks through the whole pipeline, from the event data model and a range-based API endpoint to a working calendar with category filters and a details dialog. It ends with an honest look at which library fits which kind of project.
What a JavaScript Event Calendar Needs to Do
An event calendar isn't the same as a scheduler. A scheduler helps people book slots or assign resources (see JavaScript Calendar Scheduler: Choosing and Building One for that). An event calendar shows things that are already happening: concerts at a venue, meetups, webinars, school holidays, release dates. Visitors mostly browse it.
The requirements are fairly consistent across projects:
- A month grid for the overview, plus week and day views for busy periods.
- A list of upcoming events, which is often the most useful view on mobile.
- Loading by date range, so the calendar never downloads your whole events table.
- Category colors and filters ("Music", "Talks", "Community").
- A details view on click: date, time, venue, description and a link to the event page.
- Correct handling of multi-day, all-day and recurring events, and of time zones.
Most of the hard bugs come from points 3 and 6, so the data comes first.
Designing the Event Data
Almost every calendar library expects roughly the same event shape: an id, a title, a start, an end and some display hints. Store more in your database, but this is the contract between your API and the calendar:
| Field | Purpose | Notes |
|---|---|---|
id | Stable unique key | Needed for updates and drag & drop |
title | Text on the event | Treat user-written titles as plain text |
start, end | When it happens | ISO 8601 strings with an offset, or local date-time strings |
allDay | Whole-day events | Festivals, holidays, deadlines |
category | Your own field | Drives the color and filters |
summary, url, venue | Details | Shown in a tooltip or dialog |
The date-only string trap
The most common bug in a JavaScript event calendar is an event that shows up a day early. The cause is in the language itself. MDN states that when the offset is missing, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time, and describes this as a historical spec error that can't be fixed for web-compatibility reasons. So new Date('2026-11-21') is midnight UTC, which is still 20 November anywhere west of Greenwich.
Two rules avoid it:
- For timed events, send full timestamps with an offset, for example
2026-11-21T19:30:00Zor2026-11-21T19:30:00+01:00. The browser converts them to the visitor's local time. - For all-day events, send local date-time strings without an offset, such as
2026-11-21T00:00. They are read as local midnight and stay on the right day.
Multi-day events and range queries
A three-day festival starting on 30 October belongs on the November grid too. Your API must return every event that overlaps the requested range, not just the ones that start inside it:
SELECT id, title, category, starts_at, ends_at, all_day, summary, url, venue FROM events WHERE starts_at < :range_end AND ends_at > :range_start ORDER BY starts_at;
That one condition handles events that start before the range, end after it, or span the whole thing.
Need a calendar in your app?
SimpleCalendarJS: ~14 KB gzipped, zero dependencies, works in any framework. Free for personal use.
Serving Events by Date Range
Mature calendars all fetch by visible range. FullCalendar's documentation describes its events function receiving a start and an exclusive end whenever the user navigates or switches views. Its JSON feed sends them as ISO 8601 query parameters such as ?start=2013-12-01T00:00:00-05:00&end=2014-01-12T00:00:00-05:00. EventCalendar's URL sources likewise send start and end. Copy that convention and your endpoint will work with nearly any library.
Here is a minimal Node.js endpoint with Express. Replace db.query with your own database client:
import express from 'express'; import { db } from './db.js'; // your database client const app = express(); app.get('/api/events', async (req, res) => { const start = new Date(req.query.start); const end = new Date(req.query.end); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return res.status(400).json({ error: 'start and end must be ISO dates' }); } const rows = await db.query( `SELECT id, title, category, starts_at, ends_at, all_day, summary, url, venue FROM events WHERE starts_at < $2 AND ends_at > $1 ORDER BY starts_at`, [start, end] ); res.json(rows.map((r) => ({ id: r.id, title: r.title, category: r.category, // timed events: UTC with "Z"; all-day events: local date-time without offset start: r.all_day ? `${r.starts_at.toISOString().slice(0, 10)}T00:00` : r.starts_at.toISOString(), end: r.all_day ? `${r.ends_at.toISOString().slice(0, 10)}T23:59` : r.ends_at.toISOString(), allDay: r.all_day, summary: r.summary, url: r.url, venue: r.venue }))); }); app.listen(3000);
The all-day branch assumes you store all-day dates as UTC midnight, which is a common convention. If you store a plain DATE column, format it as YYYY-MM-DD directly. Either way, the browser should never receive a bare date-only string.
Keep the payload small. The calendar only needs the fields in the table above. A long description can be fetched when the dialog opens.
Building the JavaScript Event Calendar With SimpleCalendarJS
SimpleCalendarJS is a zero-dependency calendar component with month, week, day and list views. It weighs about 14 KB gzipped for the JS and 19 KB gzipped with the CSS. It loads events through a single async function, fetchEvents(start, end), which it calls with the visible date range, so it plugs straight into the endpoint above. It works in vanilla JS and in any framework. For React, Vue, Svelte or Angular, create it in the component's mount hook and call destroy() on unmount (the docs show each one).
The markup
The container gets an inline height so month view stays fixed and busy days show "+N more" instead of growing. The details dialog uses the native dialog element:
<fieldset class="filters"> <legend>Show</legend> <label><input type="checkbox" value="music" checked> Music</label> <label><input type="checkbox" value="talks" checked> Talks</label> <label><input type="checkbox" value="community" checked> Community</label> </fieldset> <p id="calendar-status" role="status"></p> <div id="calendar" style="height: 640px"></div> <dialog id="event-dialog"> <h2 id="event-title"></h2> <p id="event-when"></p> <p id="event-description"></p> <a id="event-link" href="#">Event page</a> <form method="dialog"><button>Close</button></form> </dialog>
The calendar, filters and details dialog
import SimpleCalendarJs from 'simple-calendar-js'; import 'simple-calendar-js/dist/simple-calendar-js.min.css'; const CATEGORY_COLORS = { music: '#7c3aed', talks: '#0369a1', community: '#15803d' }; const activeCategories = new Set(Object.keys(CATEGORY_COLORS)); const responseCache = new Map(); // range key -> rows from the API const statusEl = document.querySelector('#calendar-status'); async function loadRange(start, end) { const key = `${start.toISOString()}|${end.toISOString()}`; if (!responseCache.has(key)) { const params = new URLSearchParams({ start: start.toISOString(), end: end.toISOString() }); const res = await fetch(`/api/events?${params}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); responseCache.set(key, await res.json()); } return responseCache.get(key); } function toCalendarEvent(row) { return { id: row.id, title: row.title, start: row.start, end: row.end, allDay: row.allDay, color: CATEGORY_COLORS[row.category] ?? '#475569', description: row.summary, // used for the hover tooltip // extra fields are kept and passed back in callbacks category: row.category, url: row.url, venue: row.venue }; } const calendar = new SimpleCalendarJs('#calendar', { defaultView: 'month', enabledViews: ['month', 'week', 'day', 'list'], listDaysForward: 60, locale: 'en-US', weekStartsOn: 1, autoContrastText: true, allowHtmlInEvents: false, // titles come from organizers: show as plain text tooltipAllowHtml: false, fetchEvents: async (start, end) => { try { const rows = await loadRange(start, end); statusEl.textContent = ''; return rows .filter((row) => activeCategories.has(row.category)) .map(toCalendarEvent); } catch (err) { console.error(err); statusEl.textContent = 'Events could not be loaded. Please try again later.'; return []; } }, onEventClick: (event) => openDetails(event) }); // Category filters: reuse cached API responses, just re-run the filter document.querySelectorAll('.filters input').forEach((box) => { box.addEventListener('change', () => { if (box.checked) activeCategories.add(box.value); else activeCategories.delete(box.value); calendar.refresh(); }); }); // Details dialog const dialog = document.querySelector('#event-dialog'); const timedFormat = new Intl.DateTimeFormat('en-US', { dateStyle: 'full', timeStyle: 'short' }); const dayFormat = new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }); function openDetails(event) { const fmt = event.allDay ? dayFormat : timedFormat; const when = `${fmt.format(new Date(event.start))} – ${fmt.format(new Date(event.end))}`; dialog.querySelector('#event-title').textContent = event.title; dialog.querySelector('#event-when').textContent = event.venue ? `${when} · ${event.venue}` : when; dialog.querySelector('#event-description').textContent = event.description ?? ''; const link = dialog.querySelector('#event-link'); link.hidden = !event.url; if (event.url) link.href = event.url; dialog.showModal(); } // When your data changes (e.g. after an organizer publishes an event): export function reloadEvents() { responseCache.clear(); calendar.refresh(); }
A few details make this hold up in production:
- Two caches, two jobs. SimpleCalendarJS caches results per month grid.
refresh()clears that cache and callsfetchEventsagain. TheresponseCachemap means toggling a filter re-runs the filter without another network request. When the data itself changes, clear both, which is whatreloadEvents()does. - Failures return an empty array and show a message in a
role="status"element, so the calendar never gets stuck half-rendered. - Colors come from the category, and
autoContrastTextpicks a readable text color for each background. - The list view (
'list'inenabledViews) shows the nextlistDaysForwarddays of events. On a phone it's often a better landing view than the month grid; setdefaultView: 'list'for small screens if you want.
No build step? Load dist/simple-calendar-js.min.css and dist/simple-calendar-js.min.js from unpkg and use the global SimpleCalendarJs. The rest of the code stays the same. You can try options live in the sandbox. For a version where visitors sign up for events, see How to Build an Event Registration Page with a JavaScript Calendar. If you'd rather organize the calendar around callbacks and optimistic updates, read Building Event-Driven Applications with a JavaScript Calendar.
What it doesn't do
Some limits you should know up front: SimpleCalendarJS has no recurring-event (RRULE) support, no time zone option (dates display in the browser's local time), no resource or timeline views, no built-in event editing form, and no iCal export or Google Calendar sync. It's free for personal, educational and open source projects. A commercial event calendar (client site, SaaS, paid product, internal tool) needs a paid license; see pricing.
Need a calendar in your app?
SimpleCalendarJS: ~14 KB gzipped, zero dependencies, works in any framework. Free for personal use.
Recurring Events and Time Zones
Expanding recurring events
Weekly meetups and monthly workshops are a staple of event calendars. If your library has no recurrence engine, or you'd rather keep that logic in one place, expand the series into individual occurrences for the requested range. You can do this on the server or inside fetchEvents. Here is a dependency-free weekly expander:
// series: { id, title, category, firstStart: '2026-09-07T18:30', firstEnd: '2026-09-07T20:00', // intervalWeeks: 1, until: '2027-06-30T23:59', exceptions: ['2026-12-28'] } function expandWeekly(series, rangeStart, rangeEnd) { const occurrences = []; const first = new Date(series.firstStart); const durationMs = new Date(series.firstEnd) - first; const until = series.until ? new Date(series.until) : rangeEnd; const skip = new Set(series.exceptions ?? []); const cursor = new Date(first); let index = 0; while (cursor < rangeEnd && cursor <= until) { const end = new Date(cursor.getTime() + durationMs); const localDay = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`; if (end > rangeStart && !skip.has(localDay)) { occurrences.push({ id: `${series.id}-${index}`, // stable per occurrence title: series.title, category: series.category, start: new Date(cursor), end, seriesId: series.id }); } // setDate keeps the local wall-clock time across daylight-saving changes cursor.setDate(cursor.getDate() + 7 * (series.intervalWeeks ?? 1)); index++; } return occurrences; }
Merge the result with your one-off events before mapping them to calendar objects. Because the cursor moves with setDate, a 18:30 meetup stays at 18:30 after the clocks change, which a naive "add 7 × 24 hours" loop gets wrong.
For anything beyond simple weekly or monthly rules, such as "the second Tuesday of every month" or imported iCalendar data, use a dedicated library like rrule. Its between(after, before) method returns the occurrences inside a range, which matches the range-based fetch exactly. Its README advises building inputs with its datetime() helper instead of new Date(...), because local offsets can shift results. Read that section before wiring it up.
Time zones
If your API sends UTC timestamps with a Z, every visitor sees events in their own local time automatically. That's right for webinars. For an in-person event at a venue, visitors usually want the venue's time. You have two options:
- Show the venue time in the details dialog.
Intl.DateTimeFormataccepts any IANA name in itstimeZoneoption, for examplenew Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Lisbon' }). - Shift the times before returning them so the grid shows venue wall-clock time. Libraries with a time zone option, such as FullCalendar's
timeZonesetting, handle this for you. With SimpleCalendarJS you convert the times yourself, because it has no time zone option.
Choosing a JavaScript Event Calendar Library
All four libraries below can drive the pattern in this guide. They differ in scope and licensing:
| Library | License (as of 2026) | Views | Loading events | Worth knowing |
|---|---|---|---|---|
| SimpleCalendarJS | Free for personal, educational and OSS; paid license for commercial use | Month, week, day, list; date and range picker modes | fetchEvents(start, end) | ~14 KB gzipped JS, zero dependencies; no recurrence, time zone or resource views |
| FullCalendar (standard) | MIT; premium plugins need a commercial license (with free non-profit and AGPLv3 options) | Day grid, time grid, list; timeline and resources via premium plugins | Events function or JSON feed with ISO start/end | Large ecosystem, timeZone option |
| EventCalendar (vkurko) | MIT | Day grid, time grid, list, resource time grid, resource timeline | events array or eventSources (URL or function) | README states 35 KB brotli for the standalone bundle; FullCalendar-like API |
| DayPilot Lite | Apache 2.0 | Day, week, work week, month, timeline, resource calendar | Your code sets events | Built-in edit modal, TypeScript definitions, React/Vue/Angular packages |
Some rough guidance:
- If you need resources or timelines (rooms, staff, stages side by side), pick EventCalendar, DayPilot Lite or FullCalendar's premium plugins. SimpleCalendarJS doesn't have those views.
- If you need recurrence or time zone handling built in, FullCalendar's ecosystem covers more ground.
- If you want a small, framework-agnostic month/week/day/list calendar that drops into a static page, a CMS template or any framework and loads events from one function, SimpleCalendarJS is a good fit.
For bundle-size numbers across more libraries, see Best JavaScript Calendar Libraries in 2026 — Compared by Bundle Size.
Sources & Further Reading
- Date — JavaScript (date time string format) — MDN
- Date() constructor — MDN
- Intl.DateTimeFormat() constructor — MDN
- URLSearchParams — MDN
- events (as a function) — FullCalendar Docs
- events (as a json feed) — FullCalendar Docs
- Event Source Object — FullCalendar Docs
- License — FullCalendar
- vkurko/calendar (EventCalendar) — GitHub
- DayPilot Lite for JavaScript README — jsDelivr
- jkbrzt/rrule — GitHub
Image Credits
- Cover: Desk setup with 2025 calendar and laptop — Pexels
Image free to use under the Pexels License.
Frequently Asked Questions
How do I show multi-day events in a JavaScript event calendar?
Give the event a start on its first day and an end on its last day. In the API, select it whenever it overlaps the visible range (starts before the range ends and ends after the range starts). If you only check whether the start falls inside the range, events that began before the visible month won't appear.
How do I add a new event after the calendar has loaded?
Save the event to your backend or local array first. Clear any response cache you keep yourself, then call refresh() on the SimpleCalendarJS instance. That clears the calendar's own cache and calls fetchEvents again for the visible range, so the new event appears without reloading the page.
How do I open an event's page when someone clicks it?
Add your own field such as url to each event object. SimpleCalendarJS keeps extra fields and passes them back in callbacks, so onEventClick: (event) => { window.location.href = event.url; } sends the visitor to the event's page. You can also open a details dialog first.
Can a JavaScript event calendar work without a build step?
Yes. SimpleCalendarJS ships a UMD build, so you can load the CSS and JS from unpkg with a link tag and a script tag and use the global SimpleCalendarJs class. That works on static HTML pages, CMS templates and server-rendered apps with no bundler.
Is SimpleCalendarJS free to use for an event calendar?
It's free for personal, educational and open source projects. A commercial event calendar, such as one on a client website, a paid product, a SaaS app or an internal company tool, needs a paid license, one per project. The pricing page lists the annual and lifetime options.
Should event titles from users be rendered as HTML?
No. SimpleCalendarJS sanitizes HTML in event titles and tooltips by default, but if visitors or organizers write the titles, set allowHtmlInEvents and tooltipAllowHtml to false so the text is shown exactly as typed. Also escape it on the server like any other user content.
Add a calendar to your app today
Free for personal projects. $49/year or $199 lifetime per commercial project.



