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
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
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.
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 timestamp7// - 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 timestamp15// - 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.
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.
1const { RRule, rrulestr } = require('rrule');2const { db } = require('../db');3const { events, calendars } = require('../../shared/schema');4const { eq, and, lte, gte, sql } = require('drizzle-orm');56router.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' });910 const startDate = new Date(start);11 const endDate = new Date(end);1213 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 }2223 // Get all events that could appear in range24 // Include events starting before range end AND25 // recurring events that haven't ended before range start26 const rawEvents = await db.execute(27 sql`SELECT e.*, c.color AS calendar_color, c.name AS calendar_name28 FROM events e29 JOIN calendars c ON c.id = e.calendar_id30 WHERE c.user_id = ${req.user.id}31 AND (${calendarId ? sql`c.id = ${Number(calendarId)}` : sql`TRUE`})32 AND e.parent_event_id IS NULL33 AND (34 (e.recurrence_rule IS NULL AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()})35 OR36 (e.recurrence_rule IS NOT NULL AND (e.recurrence_end IS NULL OR e.recurrence_end >= ${startDate.toISOString().split('T')[0]}))37 )`38 );3940 // Get exception instances (single edits of recurring events)41 const exceptions = await db.execute(42 sql`SELECT e.* FROM events e43 JOIN calendars c ON c.id = e.calendar_id44 WHERE c.user_id = ${req.user.id}45 AND e.parent_event_id IS NOT NULL46 AND e.start_time < ${endDate.toISOString()}47 AND e.end_time > ${startDate.toISOString()}`48 );4950 const exceptionDates = new Set(exceptions.rows.map(e => `${e.parent_event_id}-${e.recurrence_exception_date}`));5152 const result = [];5354 for (const event of rawEvents.rows) {55 if (!event.recurrence_rule) {56 result.push(event);57 continue;58 }5960 // Expand recurring event instances within the range61 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);6768 for (const instanceStart of instances) {69 const dateKey = `${event.id}-${instanceStart.toISOString().split('T')[0]}`;70 if (exceptionDates.has(dateKey)) continue; // skip — exception exists7172 result.push({73 ...event,74 id: `${event.id}-${instanceStart.toISOString()}`, // virtual ID for instances75 start_time: instanceStart.toISOString(),76 end_time: new Date(instanceStart.getTime() + duration).toISOString(),77 is_recurring_instance: true,78 master_event_id: event.id79 });80 }81 } catch (err) {82 console.error('RRULE parse error for event', event.id, err.message);83 }84 }8586 // Add exception instances87 result.push(...exceptions.rows);8889 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.
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.
1// Prompt to type into Replit Agent:2// Add these routes to server/routes/events.js:3//4// POST /api/events — create event5// Body: {calendarId, title, description, startTime, endTime, allDay,6// location, recurrenceRule, recurrenceEnd, attendeeEmails, reminders}7// Validate calendarId belongs to req.user.id8// 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 attendees12//13// PUT /api/events/:id — update entire event (non-recurring or all instances)14// Validate ownership via calendar join15// Update the event row, return updated event16//17// POST /api/events/:id/exception — edit single instance of recurring event18// Body: {instanceDate (the original date), title, startTime, endTime, description}19// Create a new event row with:20// parent_event_id = :id, recurrence_exception_date = instanceDate21// all other fields from the body22// The GET /api/events endpoint already checks exceptionDates to skip the original instance23//24// DELETE /api/events/:id — delete event (non-recurring)25// Cascades to attendees and reminders via ON DELETE CASCADE in schema26//27// POST /api/events/:id/rsvp — RSVP to event28// Body: {status: pending/accepted/declined/tentative}29// UPDATE event_attendees SET rsvp_status = status30// WHERE event_id = :id AND email = req.user.email31//32// GET /api/events/today — today's events for a widget33// Return events where start_time >= today 00:00 AND start_time < today+1 00:00Expected 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.
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.
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 manager6//7// 2. Main CalendarPage component:8// - Import FullCalendar with plugins: dayGridPlugin, timeGridPlugin, interactionPlugin9// - 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 with13// FullCalendar's start and end dates, returns the events array14// - eventClick: opens EventDetailModal with clicked event15// - dateClick: opens CreateEventModal with clicked date pre-filled16// - eventColor per calendar: color from calendar.color field17//18// 3. CreateEventModal:19// - Title input (required)20// - Date/time pickers for start and end21// - All-day toggle22// - Calendar selector (user's calendars)23// - Location input24// - Recurrence selector: None / Daily / Weekly (specific days) / Monthly25// 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 picker28// - Attendees: comma-separated email input29// - Submit → POST /api/events30//31// 4. Sidebar: list of user's calendars with color dot and visibility checkbox32// Toggle visibility hides/shows that calendar's events in FullCalendarPro 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.
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.
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.email5// FROM events e6// JOIN event_reminders er ON er.event_id = e.id7// JOIN event_attendees ea ON ea.event_id = e.id8// 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 duplicates13// 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 applicable16// 4. Insert into sent_reminders to mark as sent17//18// Then deploy:19// 1. Add SENDGRID_API_KEY and FROM_EMAIL to Replit Secrets (lock icon)20// 2. Add SESSION_SECRET to Replit Secrets21// 3. Ensure server/index.js binds to 0.0.0.022// 4. Deploy main app: Deploy → Autoscale23// 5. Deploy reminder script: Deploy → Scheduled → every 15 minutes24// Command: node scripts/sendReminders.jsPro 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
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');67const router = Router();89router.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' });1314 const startDate = new Date(start);15 const endDate = new Date(end);1617 const rawEvents = await db.execute(18 sql`SELECT e.*, c.color AS calendar_color, c.name AS calendar_name19 FROM events e JOIN calendars c ON c.id = e.calendar_id20 WHERE c.user_id = ${req.user.id}21 AND e.parent_event_id IS NULL22 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 );2728 const exceptions = await db.execute(29 sql`SELECT e.* FROM events e JOIN calendars c ON c.id = e.calendar_id30 WHERE c.user_id = ${req.user.id} AND e.parent_event_id IS NOT NULL31 AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()}`32 );3334 const exceptionKeys = new Set(exceptions.rows.map(e => `${e.parent_event_id}-${e.recurrence_exception_date}`));35 const result = [];3637 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 }5455 result.push(...exceptions.rows);56 result.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));57 res.json(result);58});5960module.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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation