Skip to main content
RapidDev - Software Development Agency

How to Build a Event Calendar App with Replit

Build a Google Calendar alternative in Replit in 1-2 hours. Users create events with recurrence rules, invite attendees with RSVP tracking, and view their schedule in month, week, and day views. Recurring events use the rrule npm package for expansion. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

What you'll build

  • Calendar and event tables with recurrence rule support using iCal RRULE format
  • Recurring event expansion API that generates instances within a date range using the rrule package
  • Event attendee system with RSVP status tracking (pending/accepted/declined/tentative)
  • Month, week, and day view endpoints returning events in the requested date range
  • Event reminder system with configurable minutes-before and a Scheduled Deployment for delivery
  • Color-coded calendar management with visibility toggles
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a Google Calendar alternative in Replit in 1-2 hours. Users create events with recurrence rules, invite attendees with RSVP tracking, and view their schedule in month, week, and day views. Recurring events use the rrule npm package for expansion. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

What you're building

Calendar apps seem simple until you add recurring events. 'Every Monday and Wednesday until December 31' becomes a complex algorithmic problem — how do you store it? How do you query it for a date range? This project uses the rrule npm package (the same standard iCal RRULE format Google Calendar uses) to solve this correctly without reinventing the wheel.

Replit Agent generates the backend in one prompt: Drizzle schema with calendars, events, attendees, and reminders tables. The recurring event expansion is server-side — when a client requests events for a date range, the API parses each event's recurrence_rule, expands it using rrule.between(), and returns individual instances mixed with non-recurring events. Editing a single recurring instance creates an exception event row rather than modifying the master rule.

The reminder system uses a Scheduled Deployment that checks for events in the next 30-60 minutes and sends email notifications via SendGrid. The main calendar app runs on Autoscale; the reminder cron runs as a separate Scheduled Deployment. Everything uses Replit's built-in PostgreSQL — no external database needed.

Final result

A fully functional calendar app with recurring events, attendee RSVP, color-coded calendars, and email reminders — all running on Replit's built-in PostgreSQL.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
rruleRecurrence Rule Expansion

Prerequisites

  • A Replit account (free tier is sufficient)
  • Basic understanding of what a calendar event and a database table are (no coding needed)
  • Optional: SendGrid account for email reminders (free tier — 100 emails/day)
  • No external API keys required for the core calendar features

Build steps

1

Set up the project and schema with Replit Agent

The schema design is critical for calendar apps. The recurrence_rule column stores iCal RRULE strings, not pre-expanded dates. This keeps storage minimal and allows editing the recurrence pattern without touching hundreds of rows.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a calendar app with Express and PostgreSQL using Drizzle ORM.
3// Create these tables in shared/schema.ts:
4// - calendars: id serial pk, user_id text not null, name text not null,
5// color text not null default '#3B82F6',
6// is_default boolean default false, created_at timestamp
7// - events: id serial pk, calendar_id integer references calendars,
8// title text not null, description text, start_time timestamp not null,
9// end_time timestamp not null, all_day boolean default false,
10// location text, color text,
11// recurrence_rule text (iCal RRULE format e.g. 'FREQ=WEEKLY;BYDAY=MO,WE'),
12// recurrence_end date, recurrence_exception_date date (for single-instance edits),
13// parent_event_id integer references events (for exception instances),
14// created_by text not null, created_at timestamp, updated_at timestamp
15// - event_attendees: id serial pk, event_id integer references events,
16// user_id text, email text not null,
17// rsvp_status text default 'pending' (pending/accepted/declined/tentative),
18// UNIQUE on (event_id, email)
19// - event_reminders: id serial pk, event_id integer references events,
20// minutes_before integer not null default 30,
21// method text default 'in_app' (in_app/email)
22// Install the rrule npm package. Set up Replit Auth. Bind server to 0.0.0.0.

Pro tip: After Agent creates the schema, run a quick test in Drizzle Studio (database icon in sidebar): create a calendar row manually to confirm the tables exist and the color column accepts hex strings.

Expected result: Agent creates shared/schema.ts with all four tables, server/index.js with route stubs, and installs the rrule package. Drizzle migrations run automatically.

2

Build the events query API with recurring event expansion

The events endpoint is the most important route. It must return both regular events and expanded recurring event instances within a date range in a single response. The rrule package handles the math.

server/routes/events.js
1const { RRule, rrulestr } = require('rrule');
2const { db } = require('../db');
3const { events, calendars } = require('../../shared/schema');
4const { eq, and, lte, gte, sql } = require('drizzle-orm');
5
6router.get('/api/events', async (req, res) => {
7 const { start, end, calendarId } = req.query;
8 if (!start || !end) return res.status(400).json({ error: 'start and end query params required' });
9
10 const startDate = new Date(start);
11 const endDate = new Date(end);
12
13 const userCalendars = await db.query.calendars.findMany({
14 where: eq(calendars.userId, req.user.id)
15 });
16 const calendarIds = userCalendars.map(c => c.id);
17 if (calendarId) {
18 if (!calendarIds.includes(Number(calendarId))) {
19 return res.status(403).json({ error: 'Not your calendar' });
20 }
21 }
22
23 // Get all events that could appear in range
24 // Include events starting before range end AND
25 // recurring events that haven't ended before range start
26 const rawEvents = await db.execute(
27 sql`SELECT e.*, c.color AS calendar_color, c.name AS calendar_name
28 FROM events e
29 JOIN calendars c ON c.id = e.calendar_id
30 WHERE c.user_id = ${req.user.id}
31 AND (${calendarId ? sql`c.id = ${Number(calendarId)}` : sql`TRUE`})
32 AND e.parent_event_id IS NULL
33 AND (
34 (e.recurrence_rule IS NULL AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()})
35 OR
36 (e.recurrence_rule IS NOT NULL AND (e.recurrence_end IS NULL OR e.recurrence_end >= ${startDate.toISOString().split('T')[0]}))
37 )`
38 );
39
40 // Get exception instances (single edits of recurring events)
41 const exceptions = await db.execute(
42 sql`SELECT e.* FROM events e
43 JOIN calendars c ON c.id = e.calendar_id
44 WHERE c.user_id = ${req.user.id}
45 AND e.parent_event_id IS NOT NULL
46 AND e.start_time < ${endDate.toISOString()}
47 AND e.end_time > ${startDate.toISOString()}`
48 );
49
50 const exceptionDates = new Set(exceptions.rows.map(e => `${e.parent_event_id}-${e.recurrence_exception_date}`));
51
52 const result = [];
53
54 for (const event of rawEvents.rows) {
55 if (!event.recurrence_rule) {
56 result.push(event);
57 continue;
58 }
59
60 // Expand recurring event instances within the range
61 try {
62 const duration = new Date(event.end_time) - new Date(event.start_time);
63 const dtstart = new Date(event.start_time);
64 const ruleString = `DTSTART:${dtstart.toISOString().replace(/[-:.]/g, '').slice(0, 15)}Z\nRRULE:${event.recurrence_rule}`;
65 const rule = rrulestr(ruleString);
66 const instances = rule.between(startDate, endDate, true);
67
68 for (const instanceStart of instances) {
69 const dateKey = `${event.id}-${instanceStart.toISOString().split('T')[0]}`;
70 if (exceptionDates.has(dateKey)) continue; // skip — exception exists
71
72 result.push({
73 ...event,
74 id: `${event.id}-${instanceStart.toISOString()}`, // virtual ID for instances
75 start_time: instanceStart.toISOString(),
76 end_time: new Date(instanceStart.getTime() + duration).toISOString(),
77 is_recurring_instance: true,
78 master_event_id: event.id
79 });
80 }
81 } catch (err) {
82 console.error('RRULE parse error for event', event.id, err.message);
83 }
84 }
85
86 // Add exception instances
87 result.push(...exceptions.rows);
88
89 result.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
90 res.json(result);
91});

Pro tip: The rrule package expects DTSTART in the rule string to compute instances relative to the event's start time. Construct the full DTSTART + RRULE string before parsing to get correct instance times.

Expected result: GET /api/events?start=2026-05-01&end=2026-05-31 returns all events and expanded recurring instances for May. A weekly Monday event correctly generates ~4-5 Monday instances within the range.

3

Build event creation, editing, and RSVP routes

Event creation stores the RRULE string as-is. Editing a single recurring instance creates an exception row rather than modifying the master event. Attendee RSVP is a simple upsert.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add these routes to server/routes/events.js:
3//
4// POST /api/events — create event
5// Body: {calendarId, title, description, startTime, endTime, allDay,
6// location, recurrenceRule, recurrenceEnd, attendeeEmails, reminders}
7// Validate calendarId belongs to req.user.id
8// Insert event row, then for each attendeeEmail:
9// INSERT INTO event_attendees (event_id, email, rsvp_status='pending')
10// For each reminder: INSERT INTO event_reminders (event_id, minutes_before, method)
11// Return event with attendees
12//
13// PUT /api/events/:id — update entire event (non-recurring or all instances)
14// Validate ownership via calendar join
15// Update the event row, return updated event
16//
17// POST /api/events/:id/exception — edit single instance of recurring event
18// Body: {instanceDate (the original date), title, startTime, endTime, description}
19// Create a new event row with:
20// parent_event_id = :id, recurrence_exception_date = instanceDate
21// all other fields from the body
22// The GET /api/events endpoint already checks exceptionDates to skip the original instance
23//
24// DELETE /api/events/:id — delete event (non-recurring)
25// Cascades to attendees and reminders via ON DELETE CASCADE in schema
26//
27// POST /api/events/:id/rsvp — RSVP to event
28// Body: {status: pending/accepted/declined/tentative}
29// UPDATE event_attendees SET rsvp_status = status
30// WHERE event_id = :id AND email = req.user.email
31//
32// GET /api/events/today — today's events for a widget
33// Return events where start_time >= today 00:00 AND start_time < today+1 00:00

Expected result: Creating an event with recurrenceRule='FREQ=WEEKLY;BYDAY=MO' stores the rule in the database. The GET /api/events endpoint expands it into individual Monday instances on each request.

4

Build the React calendar frontend

The calendar UI is the most visible part of the app. Use @fullcalendar/react to handle the complex month/week/day grid rendering. Your job is wiring it to the API and handling the event creation modal.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build the calendar frontend at client/src/pages/CalendarPage.jsx:
3//
4// 1. Install @fullcalendar/react, @fullcalendar/core, @fullcalendar/daygrid,
5// @fullcalendar/timegrid, @fullcalendar/interaction via Replit package manager
6//
7// 2. Main CalendarPage component:
8// - Import FullCalendar with plugins: dayGridPlugin, timeGridPlugin, interactionPlugin
9// - initialView='dayGridMonth'
10// - headerToolbar: { left: 'prev,next today', center: 'title',
11// right: 'dayGridMonth,timeGridWeek,timeGridDay' }
12// - events: async function that calls GET /api/events?start=X&end=Y with
13// FullCalendar's start and end dates, returns the events array
14// - eventClick: opens EventDetailModal with clicked event
15// - dateClick: opens CreateEventModal with clicked date pre-filled
16// - eventColor per calendar: color from calendar.color field
17//
18// 3. CreateEventModal:
19// - Title input (required)
20// - Date/time pickers for start and end
21// - All-day toggle
22// - Calendar selector (user's calendars)
23// - Location input
24// - Recurrence selector: None / Daily / Weekly (specific days) / Monthly
25// When Weekly is selected, show day-of-week checkboxes (Mon-Sun)
26// Build RRULE string: e.g. 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
27// - End recurrence date picker
28// - Attendees: comma-separated email input
29// - Submit → POST /api/events
30//
31// 4. Sidebar: list of user's calendars with color dot and visibility checkbox
32// Toggle visibility hides/shows that calendar's events in FullCalendar

Pro tip: FullCalendar's events callback is called with { start, end, timeZone } whenever the user navigates to a new date range. Use these as query parameters for GET /api/events to load only visible events.

Expected result: The calendar renders events in month, week, and day views. Clicking a date opens the creation modal. Recurring events appear as separate instances on each occurrence date.

5

Add email reminders and deploy

The reminder system sends emails before events. A Scheduled Deployment runs every 15 minutes, queries events starting in the next 15-30 minutes, and sends reminder emails to attendees who enabled them.

prompt.txt
1// Prompt to type into Replit Agent:
2// Create scripts/sendReminders.js:
3// 1. Query events in the next 60 minutes:
4// SELECT e.*, er.minutes_before, ea.email
5// FROM events e
6// JOIN event_reminders er ON er.event_id = e.id
7// JOIN event_attendees ea ON ea.event_id = e.id
8// WHERE er.method = 'email'
9// AND e.start_time BETWEEN NOW() AND NOW() + interval '60 minutes'
10// AND ea.rsvp_status != 'declined'
11// 2. For each result, check a sent_reminders table (create it: event_id, email,
12// scheduled_at, sent_at) to avoid sending duplicates
13// 3. If not already sent, send reminder email via SendGrid:
14// Subject: 'Reminder: {event.title} starts in {minutes_before} minutes'
15// Body: event title, start time, location, join link if applicable
16// 4. Insert into sent_reminders to mark as sent
17//
18// Then deploy:
19// 1. Add SENDGRID_API_KEY and FROM_EMAIL to Replit Secrets (lock icon)
20// 2. Add SESSION_SECRET to Replit Secrets
21// 3. Ensure server/index.js binds to 0.0.0.0
22// 4. Deploy main app: Deploy → Autoscale
23// 5. Deploy reminder script: Deploy → Scheduled → every 15 minutes
24// Command: node scripts/sendReminders.js

Pro tip: The sent_reminders table prevents duplicate reminder emails if the Scheduled Deployment runs while a previous run is still processing. Always check this table before sending.

Expected result: The calendar app is live. Creating an event with a 15-minute reminder and an email attendee sends a reminder email 15 minutes before the event via the Scheduled Deployment.

Complete code

server/routes/events.js
1const { Router } = require('express');
2const { RRule, rrulestr } = require('rrule');
3const { db } = require('../db');
4const { events, calendars, eventAttendees } = require('../../shared/schema');
5const { eq, sql } = require('drizzle-orm');
6
7const router = Router();
8
9router.get('/api/events', async (req, res) => {
10 if (!req.user) return res.status(401).json({ error: 'Auth required' });
11 const { start, end } = req.query;
12 if (!start || !end) return res.status(400).json({ error: 'start and end required' });
13
14 const startDate = new Date(start);
15 const endDate = new Date(end);
16
17 const rawEvents = await db.execute(
18 sql`SELECT e.*, c.color AS calendar_color, c.name AS calendar_name
19 FROM events e JOIN calendars c ON c.id = e.calendar_id
20 WHERE c.user_id = ${req.user.id}
21 AND e.parent_event_id IS NULL
22 AND (
23 (e.recurrence_rule IS NULL AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()})
24 OR (e.recurrence_rule IS NOT NULL AND (e.recurrence_end IS NULL OR e.recurrence_end >= ${startDate.toISOString().split('T')[0]}))
25 )`
26 );
27
28 const exceptions = await db.execute(
29 sql`SELECT e.* FROM events e JOIN calendars c ON c.id = e.calendar_id
30 WHERE c.user_id = ${req.user.id} AND e.parent_event_id IS NOT NULL
31 AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()}`
32 );
33
34 const exceptionKeys = new Set(exceptions.rows.map(e => `${e.parent_event_id}-${e.recurrence_exception_date}`));
35 const result = [];
36
37 for (const event of rawEvents.rows) {
38 if (!event.recurrence_rule) { result.push(event); continue; }
39 try {
40 const duration = new Date(event.end_time) - new Date(event.start_time);
41 const dtstart = new Date(event.start_time);
42 const ruleStr = `DTSTART:${dtstart.toISOString().replace(/[-:.]/g,'').slice(0,15)}Z\nRRULE:${event.recurrence_rule}`;
43 const instances = rrulestr(ruleStr).between(startDate, endDate, true);
44 for (const instanceStart of instances) {
45 const key = `${event.id}-${instanceStart.toISOString().split('T')[0]}`;
46 if (exceptionKeys.has(key)) continue;
47 result.push({ ...event, id: `${event.id}-${instanceStart.toISOString()}`,
48 start_time: instanceStart.toISOString(),
49 end_time: new Date(instanceStart.getTime() + duration).toISOString(),
50 is_recurring_instance: true, master_event_id: event.id });
51 }
52 } catch (err) { console.error('RRULE error', event.id, err.message); }
53 }
54
55 result.push(...exceptions.rows);
56 result.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
57 res.json(result);
58});
59
60module.exports = router;

Customization ideas

iCal export

Add a GET /api/calendars/:id/export.ics route that generates an iCal file using the ical-generator npm package. This lets users import their calendar into Google Calendar, Apple Calendar, or Outlook.

Shared calendars

Add a calendar_members table (calendar_id, user_id, permission: view/edit). A shared calendar can be viewed or edited by other Replit Auth users. The GET /api/events endpoint checks both owned calendars and shared calendars.

Meeting rooms

Add a resources table (name, location, capacity, amenities). Events can book a resource. A conflict checker returns 409 if the resource is already booked for overlapping start_time/end_time before creating the event.

Common pitfalls

Pitfall: Pre-generating recurring event instances as individual database rows

How to avoid: Store only the RRULE string in the master event row. Expand instances at query time using the rrule package. Only exception instances (single-event edits) get their own rows.

Pitfall: Querying events by exact date match instead of overlapping ranges

How to avoid: Use an overlap condition: WHERE start_time < :rangeEnd AND end_time > :rangeStart. This correctly captures events that start before the range but end within it, and vice versa.

Pitfall: Passing raw RRULE strings from user input directly to rrulestr()

How to avoid: Wrap the rrulestr() call in a try/catch per event, log the error, and skip that event's expansion rather than failing the entire request.

Best practices

  • Store all timestamps in UTC in PostgreSQL and convert to the user's timezone in the frontend. The events table created_at and start_time should always be UTC.
  • Use the rrule npm package for all recurrence math — never hand-roll recurring date logic. Edge cases like leap years, month-end dates, and DST transitions are handled correctly by rrule.
  • Validate that calendarId belongs to req.user.id on every event write operation. Never trust client-provided calendar IDs without authorization checks.
  • Use Drizzle Studio (database icon in sidebar) to inspect event rows and verify RRULE strings look correct before testing the expansion in the API.
  • Install @fullcalendar packages via Replit's package manager (Packages icon in sidebar) rather than modifying package.json manually — it triggers automatic reinstall.
  • Deploy on Autoscale for the main calendar app and use a separate Scheduled Deployment for the reminder cron. This separates concerns and lets each component scale independently.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a calendar app with Express and PostgreSQL. I store recurring events as an RRULE string (e.g., 'FREQ=WEEKLY;BYDAY=MO,WE') in a recurrence_rule column. When querying events for a date range, I need to expand recurring event instances using the rrule npm package. I also have exception rows (single instance edits) that should replace the auto-generated instance for that date. Help me write a Node.js function that takes an array of event rows, expands recurring events using rrule.between(), skips dates that have exception rows, and returns a flat array of all event instances sorted by start_time.

Build Prompt

Add a 'Find a time' feature for multi-attendee events. Build GET /api/events/find-time with query params: attendeeEmails (comma-separated), durationMinutes, startDate, endDate. The endpoint fetches all events for each attendee in the date range, then finds time slots of durationMinutes where all attendees are free. Return an array of {start, end} suggestion slots, limited to business hours (9am-6pm).

Frequently asked questions

How do I store a 'every Tuesday and Thursday' recurring event?

Use the RRULE format: FREQ=WEEKLY;BYDAY=TU,TH. Store this string in the recurrence_rule column. The rrule package parses this and generates all Tuesday and Thursday instances within any date range you query.

How do I edit just one occurrence of a recurring event without changing the whole series?

Create an exception row: a new event row with parent_event_id pointing to the master event, recurrence_exception_date set to the original date of the instance being edited, and the new title/time in the regular event columns. The GET /api/events endpoint skips the auto-generated instance for that date and includes the exception row instead.

What's the difference between this and Google Calendar?

This calendar runs entirely in your Replit account — your data never leaves your PostgreSQL database. It lacks Google Calendar's mobile apps, real-time sync across devices, and the Google Meet integration. But you have full control over the code, data schema, and business logic.

Do I need a paid Replit plan for reminders?

Yes. Email reminders require a Scheduled Deployment (node scripts/sendReminders.js on a 15-minute cron), which requires Replit Core ($25/month). The calendar app itself works on the free plan — you just won't have automated reminders without Core.

Can attendees without a Replit account RSVP?

Yes. The event_attendees table stores email addresses, not just user IDs. You can email attendees a link like /rsvp?token=xxx where the token encodes the event_attendee row ID. The RSVP route updates the rsvp_status without requiring the attendee to log in.

How does the app handle timezone differences between attendees?

Store all event times in UTC in PostgreSQL. The frontend converts UTC to the viewing user's local timezone using JavaScript's Intl.DateTimeFormat or the luxon library. When creating events, convert the user's local time to UTC before sending to the API.

Can RapidDev build a custom calendar or scheduling system for my business?

Yes. RapidDev has built 600+ apps and can add features like meeting rooms, external calendar sync (Google Calendar API), and team scheduling with availability analytics. Book a free consultation at rapidevelopers.com.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.