Implementing Event Calendar on Website
Event calendar is more complex than it seems. Recurring events, time zones, overlapping slots, mobile UX, Google Calendar integration — each point adds a week to development. Let's break it down.
Library Selection
FullCalendar — industry standard. Supports month/week/day/list, drag-and-drop, recurring events, iCalendar, Google Calendar API. Packages for React, Vue, Angular available. Free version covers 90% of tasks, Premium adds timeline and resource views.
React Big Calendar — lighter, easier to customize, fewer features. Good for simple schedules.
TOAST UI Calendar — Korean development, good capabilities, actively maintained.
For static display (events only without interactivity) — can do without libraries.
FullCalendar: Basic React Integration
npm install @fullcalendar/react @fullcalendar/core @fullcalendar/daygrid \
@fullcalendar/timegrid @fullcalendar/list @fullcalendar/interaction
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import { EventInput, DateSelectArg, EventClickArg } from '@fullcalendar/core'
import enLocale from '@fullcalendar/core/locales/en'
export function EventCalendar() {
const [events, setEvents] = useState<EventInput[]>([])
const [selectedEvent, setSelectedEvent] = useState<EventClickArg | null>(null)
useEffect(() => {
fetch('/api/events')
.then(r => r.json())
.then(data => setEvents(data.map(mapToFullCalendarEvent)))
}, [])
function handleEventClick(info: EventClickArg) {
info.jsEvent.preventDefault()
setSelectedEvent(info)
}
function handleDateSelect(info: DateSelectArg) {
// Open event creation form
openCreateEventModal({ start: info.start, end: info.end, allDay: info.allDay })
}
return (
<>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale={enLocale}
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek',
}}
buttonText={{
today: 'Today',
month: 'Month',
week: 'Week',
day: 'Day',
list: 'List',
}}
events={events}
selectable={true}
selectMirror={true}
dayMaxEvents={3} // "+N more" instead of overflow
weekends={true}
eventClick={handleEventClick}
select={handleDateSelect}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
firstDay={1} // Week starts Monday
height="auto"
eventDisplay="block"
eventContent={renderEventContent}
/>
{selectedEvent && <EventModal event={selectedEvent} onClose={() => setSelectedEvent(null)} />}
</>
)
}
function renderEventContent(eventInfo: any) {
return (
<div className="fc-event-custom">
<span className="fc-event-dot" style={{ background: eventInfo.event.backgroundColor }} />
<span className="fc-event-title">{eventInfo.event.title}</span>
{!eventInfo.event.allDay && (
<span className="fc-event-time">{eventInfo.timeText}</span>
)}
</div>
)
}
API: Event Structure
// app/Http/Controllers/EventController.php
class EventController extends Controller
{
public function index(Request $request): JsonResponse
{
$events = Event::query()
->when($request->start, fn($q) => $q->where('end_at', '>=', $request->start))
->when($request->end, fn($q) => $q->where('start_at', '<=', $request->end))
->with(['category', 'location'])
->get()
->map(fn(Event $e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at->toIso8601String(),
'allDay' => $e->all_day,
'backgroundColor' => $e->category->color,
'borderColor' => $e->category->color,
'url' => route('events.show', $e),
'extendedProps' => [
'description' => $e->description,
'location' => $e->location?->name,
'category' => $e->category->name,
],
]);
return response()->json($events);
}
}
Recurring Events via rrule
npm install @fullcalendar/rrule rrule
import rrulePlugin from '@fullcalendar/rrule'
// In events array:
const recurringEvent: EventInput = {
id: 'weekly-standup',
title: 'Weekly Standup',
rrule: {
freq: 'weekly',
byweekday: ['mo', 'we', 'fr'],
dtstart: '2025-01-01T09:00:00',
until: '2025-12-31',
},
duration: '00:30',
backgroundColor: '#0ea5e9',
}
// Exceptions (cancelled dates)
const recurringWithExceptions: EventInput = {
...recurringEvent,
exdate: [
'2025-05-01T09:00:00', // Holiday — skip
'2025-06-12T09:00:00',
],
}
Time Zones
// Store in UTC, display in user's time zone
<FullCalendar
timeZone="local" // or specific: 'America/New_York'
// ...
/>
// If events in different TZ — convert on load
import { formatInTimeZone } from 'date-fns-tz'
function mapToFullCalendarEvent(event: ApiEvent): EventInput {
return {
...event,
start: formatInTimeZone(event.start_at, event.timezone, "yyyy-MM-dd'T'HH:mm:ss"),
end: formatInTimeZone(event.end_at, event.timezone, "yyyy-MM-dd'T'HH:mm:ss"),
}
}
Google Calendar Integration
npm install @fullcalendar/google-calendar
import googleCalendarPlugin from '@fullcalendar/google-calendar'
<FullCalendar
plugins={[dayGridPlugin, googleCalendarPlugin]}
googleCalendarApiKey="AIza..."
eventSources={[
{
googleCalendarId: '[email protected]',
backgroundColor: '#4285f4',
borderColor: '#4285f4',
},
{
googleCalendarId: '[email protected]',
backgroundColor: '#0f9d58',
display: 'background', // background highlight for holidays
},
]}
/>
Export to iCalendar (.ics)
// Generate .ics file on Laravel
// composer require eluceo/ical
use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Domain\Entity\Event;
use Eluceo\iCal\Presentation\Factory\CalendarFactory;
class ICalExportController extends Controller
{
public function export(Request $request): Response
{
$events = Event::whereHas('attendees', fn($q) => $q->where('user_id', auth()->id()))->get();
$calendar = new Calendar(array_map(function (Event $e) {
return (new \Eluceo\iCal\Domain\Entity\Event(
new \Eluceo\iCal\Domain\ValueObject\UniqueIdentifier($e->uid)
))
->setSummary($e->title)
->setDescription($e->description ?? '')
->setOccurrence(
new \Eluceo\iCal\Domain\ValueObject\TimeSpan(
new \Eluceo\iCal\Domain\ValueObject\DateTime(
\DateTimeImmutable::createFromMutable($e->start_at), true
),
new \Eluceo\iCal\Domain\ValueObject\DateTime(
\DateTimeImmutable::createFromMutable($e->end_at), true
)
)
);
}, $events->all()));
return response(
(new CalendarFactory())->createCalendar($calendar),
200,
[
'Content-Type' => 'text/calendar; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="events.ics"',
]
);
}
}
Mobile UX: Swipe for Month Navigation
// FullCalendar doesn't support swipe natively — add via touch events
function addSwipeNavigation(calendarRef: React.RefObject<FullCalendar>) {
let startX = 0
const el = document.querySelector('.fc')!
el.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].clientX
}, { passive: true })
el.addEventListener('touchend', (e: TouchEvent) => {
const diff = startX - e.changedTouches[0].clientX
const api = calendarRef.current!.getApi()
if (Math.abs(diff) > 50) {
diff > 0 ? api.next() : api.prev()
}
}, { passive: true })
}
Timeline
Static calendar with event output from API — 1 day. FullCalendar with month/week/list, modal event view and category filter — 3–4 days. With drag-and-drop editing, recurring events, iCal export, and Google Calendar sync — 2 weeks.







