# How to Build an Event Calendar App with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a calendar app with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - calendars: id serial pk, user_id text not null, name text not null,
//   color text not null default '#3B82F6',
//   is_default boolean default false, created_at timestamp
// - events: id serial pk, calendar_id integer references calendars,
//   title text not null, description text, start_time timestamp not null,
//   end_time timestamp not null, all_day boolean default false,
//   location text, color text,
//   recurrence_rule text (iCal RRULE format e.g. 'FREQ=WEEKLY;BYDAY=MO,WE'),
//   recurrence_end date, recurrence_exception_date date (for single-instance edits),
//   parent_event_id integer references events (for exception instances),
//   created_by text not null, created_at timestamp, updated_at timestamp
// - event_attendees: id serial pk, event_id integer references events,
//   user_id text, email text not null,
//   rsvp_status text default 'pending' (pending/accepted/declined/tentative),
//   UNIQUE on (event_id, email)
// - event_reminders: id serial pk, event_id integer references events,
//   minutes_before integer not null default 30,
//   method text default 'in_app' (in_app/email)
// 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.

```
const { RRule, rrulestr } = require('rrule');
const { db } = require('../db');
const { events, calendars } = require('../../shared/schema');
const { eq, and, lte, gte, sql } = require('drizzle-orm');

router.get('/api/events', async (req, res) => {
  const { start, end, calendarId } = req.query;
  if (!start || !end) return res.status(400).json({ error: 'start and end query params required' });

  const startDate = new Date(start);
  const endDate = new Date(end);

  const userCalendars = await db.query.calendars.findMany({
    where: eq(calendars.userId, req.user.id)
  });
  const calendarIds = userCalendars.map(c => c.id);
  if (calendarId) {
    if (!calendarIds.includes(Number(calendarId))) {
      return res.status(403).json({ error: 'Not your calendar' });
    }
  }

  // Get all events that could appear in range
  // Include events starting before range end AND
  // recurring events that haven't ended before range start
  const rawEvents = await db.execute(
    sql`SELECT e.*, c.color AS calendar_color, c.name AS calendar_name
        FROM events e
        JOIN calendars c ON c.id = e.calendar_id
        WHERE c.user_id = ${req.user.id}
          AND (${calendarId ? sql`c.id = ${Number(calendarId)}` : sql`TRUE`})
          AND e.parent_event_id IS NULL
          AND (
            (e.recurrence_rule IS NULL AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()})
            OR
            (e.recurrence_rule IS NOT NULL AND (e.recurrence_end IS NULL OR e.recurrence_end >= ${startDate.toISOString().split('T')[0]}))
          )`
  );

  // Get exception instances (single edits of recurring events)
  const exceptions = await db.execute(
    sql`SELECT e.* FROM events e
        JOIN calendars c ON c.id = e.calendar_id
        WHERE c.user_id = ${req.user.id}
          AND e.parent_event_id IS NOT NULL
          AND e.start_time < ${endDate.toISOString()}
          AND e.end_time > ${startDate.toISOString()}`
  );

  const exceptionDates = new Set(exceptions.rows.map(e => `${e.parent_event_id}-${e.recurrence_exception_date}`));

  const result = [];

  for (const event of rawEvents.rows) {
    if (!event.recurrence_rule) {
      result.push(event);
      continue;
    }

    // Expand recurring event instances within the range
    try {
      const duration = new Date(event.end_time) - new Date(event.start_time);
      const dtstart = new Date(event.start_time);
      const ruleString = `DTSTART:${dtstart.toISOString().replace(/[-:.]/g, '').slice(0, 15)}Z\nRRULE:${event.recurrence_rule}`;
      const rule = rrulestr(ruleString);
      const instances = rule.between(startDate, endDate, true);

      for (const instanceStart of instances) {
        const dateKey = `${event.id}-${instanceStart.toISOString().split('T')[0]}`;
        if (exceptionDates.has(dateKey)) continue; // skip — exception exists

        result.push({
          ...event,
          id: `${event.id}-${instanceStart.toISOString()}`, // virtual ID for instances
          start_time: instanceStart.toISOString(),
          end_time: new Date(instanceStart.getTime() + duration).toISOString(),
          is_recurring_instance: true,
          master_event_id: event.id
        });
      }
    } catch (err) {
      console.error('RRULE parse error for event', event.id, err.message);
    }
  }

  // Add exception instances
  result.push(...exceptions.rows);

  result.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
  res.json(result);
});
```

> 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 to type into Replit Agent:
// Add these routes to server/routes/events.js:
//
// POST /api/events — create event
//   Body: {calendarId, title, description, startTime, endTime, allDay,
//          location, recurrenceRule, recurrenceEnd, attendeeEmails, reminders}
//   Validate calendarId belongs to req.user.id
//   Insert event row, then for each attendeeEmail:
//     INSERT INTO event_attendees (event_id, email, rsvp_status='pending')
//   For each reminder: INSERT INTO event_reminders (event_id, minutes_before, method)
//   Return event with attendees
//
// PUT /api/events/:id — update entire event (non-recurring or all instances)
//   Validate ownership via calendar join
//   Update the event row, return updated event
//
// POST /api/events/:id/exception — edit single instance of recurring event
//   Body: {instanceDate (the original date), title, startTime, endTime, description}
//   Create a new event row with:
//     parent_event_id = :id, recurrence_exception_date = instanceDate
//     all other fields from the body
//   The GET /api/events endpoint already checks exceptionDates to skip the original instance
//
// DELETE /api/events/:id — delete event (non-recurring)
//   Cascades to attendees and reminders via ON DELETE CASCADE in schema
//
// POST /api/events/:id/rsvp — RSVP to event
//   Body: {status: pending/accepted/declined/tentative}
//   UPDATE event_attendees SET rsvp_status = status
//   WHERE event_id = :id AND email = req.user.email
//
// GET /api/events/today — today's events for a widget
//   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 to type into Replit Agent:
// Build the calendar frontend at client/src/pages/CalendarPage.jsx:
//
// 1. Install @fullcalendar/react, @fullcalendar/core, @fullcalendar/daygrid,
//    @fullcalendar/timegrid, @fullcalendar/interaction via Replit package manager
//
// 2. Main CalendarPage component:
//    - Import FullCalendar with plugins: dayGridPlugin, timeGridPlugin, interactionPlugin
//    - initialView='dayGridMonth'
//    - headerToolbar: { left: 'prev,next today', center: 'title',
//      right: 'dayGridMonth,timeGridWeek,timeGridDay' }
//    - events: async function that calls GET /api/events?start=X&end=Y with
//      FullCalendar's start and end dates, returns the events array
//    - eventClick: opens EventDetailModal with clicked event
//    - dateClick: opens CreateEventModal with clicked date pre-filled
//    - eventColor per calendar: color from calendar.color field
//
// 3. CreateEventModal:
//    - Title input (required)
//    - Date/time pickers for start and end
//    - All-day toggle
//    - Calendar selector (user's calendars)
//    - Location input
//    - Recurrence selector: None / Daily / Weekly (specific days) / Monthly
//      When Weekly is selected, show day-of-week checkboxes (Mon-Sun)
//      Build RRULE string: e.g. 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
//    - End recurrence date picker
//    - Attendees: comma-separated email input
//    - Submit → POST /api/events
//
// 4. Sidebar: list of user's calendars with color dot and visibility checkbox
//    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 to type into Replit Agent:
// Create scripts/sendReminders.js:
// 1. Query events in the next 60 minutes:
//    SELECT e.*, er.minutes_before, ea.email
//    FROM events e
//    JOIN event_reminders er ON er.event_id = e.id
//    JOIN event_attendees ea ON ea.event_id = e.id
//    WHERE er.method = 'email'
//      AND e.start_time BETWEEN NOW() AND NOW() + interval '60 minutes'
//      AND ea.rsvp_status != 'declined'
// 2. For each result, check a sent_reminders table (create it: event_id, email,
//    scheduled_at, sent_at) to avoid sending duplicates
// 3. If not already sent, send reminder email via SendGrid:
//    Subject: 'Reminder: {event.title} starts in {minutes_before} minutes'
//    Body: event title, start time, location, join link if applicable
// 4. Insert into sent_reminders to mark as sent
//
// Then deploy:
// 1. Add SENDGRID_API_KEY and FROM_EMAIL to Replit Secrets (lock icon)
// 2. Add SESSION_SECRET to Replit Secrets
// 3. Ensure server/index.js binds to 0.0.0.0
// 4. Deploy main app: Deploy → Autoscale
// 5. Deploy reminder script: Deploy → Scheduled → every 15 minutes
//    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 example

File: `server/routes/events.js`

```javascript
const { Router } = require('express');
const { RRule, rrulestr } = require('rrule');
const { db } = require('../db');
const { events, calendars, eventAttendees } = require('../../shared/schema');
const { eq, sql } = require('drizzle-orm');

const router = Router();

router.get('/api/events', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const { start, end } = req.query;
  if (!start || !end) return res.status(400).json({ error: 'start and end required' });

  const startDate = new Date(start);
  const endDate = new Date(end);

  const rawEvents = await db.execute(
    sql`SELECT e.*, c.color AS calendar_color, c.name AS calendar_name
        FROM events e JOIN calendars c ON c.id = e.calendar_id
        WHERE c.user_id = ${req.user.id}
          AND e.parent_event_id IS NULL
          AND (
            (e.recurrence_rule IS NULL AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()})
            OR (e.recurrence_rule IS NOT NULL AND (e.recurrence_end IS NULL OR e.recurrence_end >= ${startDate.toISOString().split('T')[0]}))
          )`
  );

  const exceptions = await db.execute(
    sql`SELECT e.* FROM events e JOIN calendars c ON c.id = e.calendar_id
        WHERE c.user_id = ${req.user.id} AND e.parent_event_id IS NOT NULL
          AND e.start_time < ${endDate.toISOString()} AND e.end_time > ${startDate.toISOString()}`
  );

  const exceptionKeys = new Set(exceptions.rows.map(e => `${e.parent_event_id}-${e.recurrence_exception_date}`));
  const result = [];

  for (const event of rawEvents.rows) {
    if (!event.recurrence_rule) { result.push(event); continue; }
    try {
      const duration = new Date(event.end_time) - new Date(event.start_time);
      const dtstart = new Date(event.start_time);
      const ruleStr = `DTSTART:${dtstart.toISOString().replace(/[-:.]/g,'').slice(0,15)}Z\nRRULE:${event.recurrence_rule}`;
      const instances = rrulestr(ruleStr).between(startDate, endDate, true);
      for (const instanceStart of instances) {
        const key = `${event.id}-${instanceStart.toISOString().split('T')[0]}`;
        if (exceptionKeys.has(key)) continue;
        result.push({ ...event, id: `${event.id}-${instanceStart.toISOString()}`,
          start_time: instanceStart.toISOString(),
          end_time: new Date(instanceStart.getTime() + duration).toISOString(),
          is_recurring_instance: true, master_event_id: event.id });
      }
    } catch (err) { console.error('RRULE error', event.id, err.message); }
  }

  result.push(...exceptions.rows);
  result.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
  res.json(result);
});

module.exports = router;
```

## Common mistakes

- **Pre-generating recurring event instances as individual database rows** — A daily recurring event for 2 years generates 730 rows. Deleting or editing the series requires updating all 730 rows. A simple meeting becomes a bulk operation. Fix: 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.
- **Querying events by exact date match instead of overlapping ranges** — A multi-day event starting on Monday and ending on Wednesday won't appear in a Tuesday range query that checks WHERE start_time = Tuesday. Fix: 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.
- **Passing raw RRULE strings from user input directly to rrulestr()** — Malformed RRULE strings throw errors that crash the event listing route if not caught. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/event-calendar-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/event-calendar-app
