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
googleapispackage - 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: truecarefully (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
-
Update
CreateEventInputSchemainschemas/calendar.ts -
Add field to form in
add-event.tsxandedit-event.tsx -
Update
buildEventData()ingoogle-calendar.tsto handle new field - No changes needed to server actions (they pass through)
-
Create function in
google-calendar.ts(e.g.,moveEventToCalendar()) -
Wrap in server action in
actions/calendar-events.tswith auth