All 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

Layer 2: Server Actions (lib/actions/calendar-events.ts)

  • 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

Layer 3: Client Components (components/calendar/)

  • 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

2. Type Safety with Zod

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

3. Google Calendar API Integration

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

Add Event Form (components/calendar/add-event.tsx)

  • Dialog-based modal using Radix UI
  • Form validation with HTML5 inputs
  • Loading states during submission
  • Error handling with user feedback

Edit Event Form (components/calendar/edit-event.tsx)

  • Pre-populates with existing event data
  • Strips [calendarTemplate] prefix from title
  • Same validation as create form
  • Optimistic UI updates with router.refresh()

7. Best Practices for Google Calendar API

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

Date and Time Handling

// 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)

Add New Calendar Operation

  1. Create function in google-calendar.ts (e.g., moveEventToCalendar())
  2. Wrap in server action in actions/calendar-events.ts with auth
  3. Call from client component

Integrate with Different Auth Provider
Replace the OAuth token retrieval in getOAuthClient():

// Example with Supabase
export async function getOAuthClient(userId: string) {
  const supabase = createClient();
  const { data } = await supabase
    .from("oauth_tokens")
    .select("google_access_token")
    .eq("user_id", userId)
    .single();

  if (!data?.google_access_token) return null;

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

  return client;
}

9. Common Patterns

Fetching Events with Filters

// Get events for a specific month
const startOfMonth = new Date(2025, 0, 1); // January 2025
const endOfMonth = new Date(2025, 1, 0); // End of January

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

Creating All-Day Events

const eventData = {
  summary: "Conference Day",
  start: { date: "2025-01-15" },
  end: { date: "2025-01-16" }, // End is exclusive
  description: "All-day event",
};

Handling Recurring Events

// Expand recurring events into individual instances
const events = await listGoogleCalendarEvents(userId, "primary", {
  singleEvents: true, // This expands recurring events
  orderBy: "startTime",
});

// Or get the original recurring event
const events = await listGoogleCalendarEvents(userId, "primary", {
  singleEvents: false, // Returns recurring event template
});

10. Google OAuth Setup

Required Scopes

<https://www.googleapis.com/auth/calendar>
<https://www.googleapis.com/auth/calendar.events>

OAuth Consent Screen

  1. Go to Google Cloud Console
  2. Navigate to "OAuth consent screen"
  3. Choose "External" for public apps
  4. Fill in required fields (app name, user support email, developer contact)
  5. Add test users during development
  6. Submit for verification for production

OAuth Credentials

  1. Create OAuth 2.0 Client ID
  2. Application type: Web application
  3. Add authorized redirect URIs
  4. Save Client ID and Client Secret

Domain Verification
For production apps using sensitive scopes:

  1. Add domain to Google Search Console
  2. Verify ownership via DNS or HTML file
  3. Record video showing scope usage
  4. Submit for OAuth verification review