Go to lists of articles

Why?

I was developing an app that uses Google Calendar heavily, and to be honest, the SDK was not intuitive. That made me create a template that implements all core features of Google Calendar.

Structure

This project uses Next.js, but without any problems, this could be re-done for, let's say, Tanstack Query and used in any other project. The most important folder is lib, where we have actions, schemas, and google-calendar.ts, which is our main file.

Before You Start

We are using custom Google Calendar scopes, which means going through the Google verification process. TL;DR: remember to add your custom domain with Google Search Console (add verify token to DNS). Another thing is you need to record a video that shows the scopes you will be using—here is the main guide.

Implementation

1. Core Architecture

The template follows a three-layer architecture:

Layer 1: Google Calendar API Wrapper (lib/google-calendar.ts)

  • Direct integration with Google Calendar API using googleapis package
  • Handles OAuth client creation and token management
  • Provides type-safe functions for all calendar operations
  • Manages serialization of Google API responses to plain objects
  • Server-side wrapper functions for calendar operations
  • Handles authentication and user context
  • Provides clean API for client components
  • Adds security layer between client and Google API
  • React components that call server actions
  • UI for creating, editing, and deleting events
  • Dual view modes (Events list and Calendar grid)
  • Form validation and loading states

All data is validated using Zod schemas in lib/schemas/calendar.ts:

// Input validation for creating events
export const CreateEventInputSchema = z.object({
  clerkUserId: z.string().min(1, "User ID is required"),
  calendarId: z.string().min(1, "Calendar ID is required"),
  title: z.string().min(1, "Event title is required"),
  description: z.string().default(""),
  location: z.string().default(""),
  date: z.date(),
  endDate: z.date().optional(),
  startTime: z.string().optional(),
  endTime: z.string().optional(),
  isAllDay: z.boolean().default(false),
  repeat: z.enum(["none", "daily", "weekly", "weekday", "biweekly", "monthly"]),
  reminder: z.enum(["at_start", "30min", "1hour", "none"]),
  tag: z.string().default(""),
});

This ensures:

  • Runtime validation of all inputs
  • Type safety across client and server
  • Automatic error messages for invalid data
  • Predictable data structure

Step 1: OAuth Client Setup

// lib/google-calendar.ts
export async function getOAuthClient(userId: string) {
  // Retrieve OAuth token from your auth provider
  const token = await getStoredOAuthToken(userId);

  const client = new google.auth.OAuth2(
    process.env.GOOGLE_OAUTH_CLIENT_ID,
    process.env.GOOGLE_OAUTH_CLIENT_SECRET,
    process.env.GOOGLE_OAUTH_REDIRECT_URL
  );

  client.setCredentials({ access_token: token });

  return client;
}

Step 2: Calendar Operations All calendar operations follow this pattern:

export async function listGoogleCalendarEvents(
  userId: string,
  calendarId: string,
  options?: {
    timeMin?: string;
    timeMax?: string;
    maxResults?: number;
    singleEvents?: boolean;
    orderBy?: "startTime" | "updated";
  }
) {
  // Get authenticated client
  const auth = await getOAuthClient(userId);

  // Initialize calendar API
  const calendar = google.calendar({ version: "v3", auth });

  // Make API request
  const response = await calendar.events.list({
    calendarId,
    timeMin: options?.timeMin,
    timeMax: options?.timeMax,
    maxResults: options?.maxResults || 250,
    singleEvents: options?.singleEvents ?? true,
    orderBy: options?.orderBy || "startTime",
  });

  // Return serializable data
  return response.data.items.map((event) => ({
    id: event.id,
    summary: event.summary,
    description: event.description,
    location: event.location,
    start: event.start,
    end: event.end,
    // ... other fields
  }));
}

Step 3: Error Handling

try {
  const events = await listGoogleCalendarEvents(userId, calendarId);
  return events;
} catch (error) {
  console.error("Error fetching calendar events:", error);

  // Handle specific Google API errors
  if (error.message.includes("404")) {
    throw new Error("Calendar not found");
  }

  if (error.message.includes("403")) {
    throw new Error("Permission denied");
  }

  throw new Error("Failed to fetch calendar events");
}

4. CRUD Operations

Create Event

// Build event data with helper functions
const eventData = {
  summary: "[calendarTemplate] Team Meeting",
  description: "Quarterly review",
  location: "Conference Room A",
  start: {
    dateTime: "2025-01-15T10:00:00Z",
    timeZone: "UTC",
  },
  end: {
    dateTime: "2025-01-15T11:00:00Z",
    timeZone: "UTC",
  },
  reminders: {
    useDefault: false,
    overrides: [{ method: "popup", minutes: 30 }],
  },
};

// Insert event via Google Calendar API
const auth = await getOAuthClient(userId);
const calendar = google.calendar({ version: "v3", auth });

const response = await calendar.events.insert({
  calendarId: "primary",
  requestBody: eventData,
});

return {
  id: response.data.id,
  htmlLink: response.data.htmlLink,
  status: response.data.status,
};

Read Events

// Fetch events with time filtering
const startOfMonth = new Date("2025-01-01T00:00:00Z");
const endOfMonth = new Date("2025-01-31T23:59:59Z");

const events = await listGoogleCalendarEvents(userId, "primary", {
  timeMin: startOfMonth.toISOString(),
  timeMax: endOfMonth.toISOString(),
  singleEvents: true, // Expand recurring events
  orderBy: "startTime",
  maxResults: 250,
});

// Returns array of serializable event objects

Update Event

// Fetch existing event first
const auth = await getOAuthClient(userId);
const calendar = google.calendar({ version: "v3", auth });

const existing = await calendar.events.get({
  calendarId: "primary",
  eventId: eventId,
});

// Merge updates with existing data
const updatedEvent = {
  ...existing.data,
  summary: "[calendarTemplate] Updated Meeting Title",
  description: "New description",
};

// Update via API
const response = await calendar.events.update({
  calendarId: "primary",
  eventId: eventId,
  requestBody: updatedEvent,
});

return {
  id: response.data.id,
  updated: response.data.updated,
};

Delete Event

// Delete event from calendar
const auth = await getOAuthClient(userId);
const calendar = google.calendar({ version: "v3", auth });

await calendar.events.delete({
  calendarId: "primary",
  eventId: eventId,
});

return { success: true, eventId };

5. Advanced Google Calendar Features

Recurring Events

// Define recurrence rules in RRULE format
function buildRecurrenceRules(repeat: string, untilDate?: Date) {
  if (!repeat || repeat === "none") return undefined;

  let baseRule: string;

  switch (repeat) {
    case "daily":
      baseRule = "RRULE:FREQ=DAILY";
      break;
    case "weekly":
      baseRule = "RRULE:FREQ=WEEKLY";
      break;
    case "weekday":
      baseRule = "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
      break;
    case "biweekly":
      baseRule = "RRULE:FREQ=WEEKLY;INTERVAL=2";
      break;
    case "monthly":
      baseRule = "RRULE:FREQ=MONTHLY";
      break;
  }

  // Add end date if provided
  if (untilDate) {
    const untilString = untilDate
      .toISOString()
      .replace(/[-:]/g, "")
      .replace(/\\.\\d{3}/, "");
    baseRule += `;UNTIL=${untilString}`;
  }

  return [baseRule];
}

// Use in event creation
const eventData = {
  summary: "Daily Standup",
  start: { dateTime: "2025-01-15T09:00:00Z" },
  end: { dateTime: "2025-01-15T09:15:00Z" },
  recurrence: ["RRULE:FREQ=DAILY;UNTIL=20250131T235959Z"],
};

Event Tags/Categories

// Store custom data in extendedProperties
const eventData = {
  summary: "Project Meeting",
  extendedProperties: {
    private: {
      tags: "work,urgent,project-alpha",
      customField: "custom-value",
    },
  },
};

// Search events by tag
async function searchEventsByTag(
  userId: string,
  calendarId: string,
  tag: string
) {
  const events = await listGoogleCalendarEvents(userId, calendarId, {
    maxResults: 100,
    timeMin: undefined, // Get all events including past
  });

  // Filter by tag in extendedProperties
  return events.filter(
    (event) =>
      event.extendedProperties?.private?.tags?.includes(tag) ||
      event.description?.includes(`#${tag}`)
  );
}

Event Reminders

// Configure custom reminders
function buildReminders(reminder: string) {
  if (!reminder || reminder === "none") return undefined;

  let minutes = 0;

  switch (reminder) {
    case "at_start":
      minutes = 0;
      break;
    case "30min":
      minutes = 30;
      break;
    case "1hour":
      minutes = 60;
      break;
    case "1day":
      minutes = 1440;
      break;
  }

  return {
    useDefault: false,
    overrides: [
      { method: "popup", minutes },
      { method: "email", minutes: minutes + 60 }, // Also send email 1hr earlier
    ],
  };
}

Calendar Sharing

// Share calendar with another user
async function shareCalendarWithEmail(
  userId: string,
  calendarId: string,
  email: string
) {
  const auth = await getOAuthClient(userId);
  const calendar = google.calendar({ version: "v3", auth });

  const response = await calendar.acl.insert({
    calendarId: calendarId,
    requestBody: {
      role: "owner", // or "reader", "writer"
      scope: {
        type: "user",
        value: email,
      },
    },
  });

  return response.data;
}

// Remove access
async function removeCalendarAccess(
  userId: string,
  calendarId: string,
  email: string
) {
  const auth = await getOAuthClient(userId);
  const calendar = google.calendar({ version: "v3", auth });

  // Find the ACL rule for this email
  const aclResponse = await calendar.acl.list({ calendarId });
  const rule = aclResponse.data.items?.find(
    (item) => item.scope?.value === email
  );

  if (rule?.id) {
    await calendar.acl.delete({
      calendarId,
      ruleId: rule.id,
    });
  }

  return { success: true, email };
}

// Get all permissions
async function getCalendarPermissions(userId: string, calendarId: string) {
  const auth = await getOAuthClient(userId);
  const calendar = google.calendar({ version: "v3", auth });

  const aclResponse = await calendar.acl.list({ calendarId });

  return (
    aclResponse.data.items?.map((item) => ({
      id: item.id,
      role: item.role,
      email: item.scope?.value,
      type: item.scope?.type,
    })) || []
  );
}

Multiple Calendars

// List all user's calendars
async function listCalendars(userId: string) {
  const auth = await getOAuthClient(userId);
  const calendar = google.calendar({ version: "v3", auth });

  const response = await calendar.calendarList.list();

  return (
    response.data.items?.map((item) => ({
      id: item.id,
      summary: item.summary,
      description: item.description,
      timeZone: item.timeZone,
      primary: item.primary || false,
    })) || []
  );
}

// Create new calendar
async function createGoogleCalendar(userId: string, calendarName: string) {
  const auth = await getOAuthClient(userId);
  const calendar = google.calendar({ version: "v3", auth });

  const response = await calendar.calendars.insert({
    requestBody: {
      summary: calendarName,
      description: "Custom calendar created via API",
      timeZone: "UTC",
    },
  });

  return {
    id: response.data.id,
    summary: response.data.summary,
    timeZone: response.data.timeZone,
  };
}

6. UI Components

Calendar View (components/calendar/calendar-view.tsx)

  • Dual view modes: Events list and Calendar grid
  • Month navigation with URL state management
  • Event badges showing count
  • Responsive design with Tailwind CSS
  • Dialog-based modal using Radix UI
  • Form validation with HTML5 inputs
  • Loading states during submission
  • Error handling with user feedback
  • Pre-populates with existing event data
  • Strips [calendarTemplate] prefix from title
  • Same validation as create form
  • Optimistic UI updates with router.refresh()

API Quotas and Rate Limiting

  • Default quota: 1,000,000 queries/day
  • Batch requests when possible
  • Use singleEvents: true carefully (expands recurring events)
  • Implement exponential backoff for rate limit errors
// Always use ISO 8601 format
const startDateTime = new Date("2025-01-15T10:00:00").toISOString();

// For all-day events, use date-only format
const allDayEvent = {
  start: { date: "2025-01-15" },
  end: { date: "2025-01-16" }, // Note: end date is exclusive
};

// Handle timezones properly
const eventWithTimezone = {
  start: {
    dateTime: "2025-01-15T10:00:00",
    timeZone: "America/New_York",
  },
  end: {
    dateTime: "2025-01-15T11:00:00",
    timeZone: "America/New_York",
  },
};

Efficient Event Filtering

// Use timeMin/timeMax to reduce response size
const events = await listGoogleCalendarEvents(userId, "primary", {
  timeMin: new Date().toISOString(), // Only future events
  timeMax: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // Next 30 days
  maxResults: 50, // Limit results
  singleEvents: true, // Expand recurring events
  orderBy: "startTime", // Requires singleEvents: true
});

Serialization for Next.js

// Google API returns non-serializable objects
// Always extract only needed fields

export async function listGoogleCalendarEvents(userId, calendarId, options) {
  const auth = await getOAuthClient(userId);
  const calendar = google.calendar({ version: "v3", auth });

  const response = await calendar.events.list({
    calendarId,
    ...options,
  });

  // Return only serializable data (no undefined, no functions)
  return (response.data.items || []).map((event) => ({
    id: event.id || null,
    summary: event.summary || null,
    description: event.description || null,
    location: event.location || null,
    start: {
      dateTime: event.start?.dateTime || null,
      date: event.start?.date || null,
      timeZone: event.start?.timeZone || null,
    },
    end: {
      dateTime: event.end?.dateTime || null,
      date: event.end?.date || null,
      timeZone: event.end?.timeZone || null,
    },
    recurrence: event.recurrence || null,
    extendedProperties: event.extendedProperties || null,
  }));
}

Error Handling

try {
  await calendar.events.insert({...});
} catch (error) {
  // Google API error codes
  if (error.code === 404) {
    throw new Error("Calendar not found");
  }

  if (error.code === 403) {
    throw new Error("Permission denied - check OAuth scopes");
  }

  if (error.code === 429) {
    throw new Error("Rate limit exceeded - implement backoff");
  }

  if (error.code === 401) {
    throw new Error("Token expired - refresh OAuth token");
  }

  console.error("Google Calendar API error:", error);
  throw new Error("Failed to create event");
}

OAuth Token Management

// Always check if token exists before using
export async function getOAuthClient(userId: string) {
  const token = await getStoredOAuthToken(userId);

  if (!token) {
    console.log("No Google OAuth token found for user:", userId);
    return null; // Handle gracefully
  }

  const client = new google.auth.OAuth2(/*...*/);
  client.setCredentials({ access_token: token });

  return client;
}

// In calling code
const auth = await getOAuthClient(userId);
if (!auth) {
  throw new Error("Unable to authenticate with Google");
}

8. Extending the Template

Add New Event Fields

  1. Update CreateEventInputSchema in schemas/calendar.ts
  2. Add field to form in add-event.tsx and edit-event.tsx
  3. Update buildEventData() in google-calendar.ts to handle new field
  4. No changes needed to server actions (they pass through)
  1. Create function in google-calendar.ts (e.g., moveEventToCalendar())
  2. Wrap in server action in actions/calendar-events.ts with auth