Skip to main content

DHTMLX Scheduler: Two-way sync with Google Calendar (Node.js)

In this guide, you will implement two-way sync between DHTMLX Scheduler and Google Calendar in a small Node.js app:

  • Load calendars and events from Google Calendar into Scheduler
  • Push Scheduler create/update/delete operations back to Google Calendar
note

This approach implements two-way sync via API calls (Scheduler → backend → Google Calendar). It does not implement real-time Google → Scheduler push updates (webhooks). If you change events directly in Google Calendar, reload the app (or reload a date range) to see the updated data in Scheduler.

You will build:

  • a Node.js + Express backend with Google OAuth 2.0 (Passport) and a small REST API for Scheduler
  • an event mapping layer (Google ↔ Scheduler), including basic recurring events/exceptions handling
  • a Scheduler client wired to the backend via scheduler.createDataProcessor()
note

The complete source code is available on GitHub.

Prerequisites​

  • Node.js 18+
  • A Google account with access to Google Cloud Console
  • Basic familiarity with TypeScript and Express
  • Access to DHTMLX Scheduler packages (the example uses @dhx/trial-scheduler)

Demo repository​

A complete working project that matches this guide is available on GitHub:

The guide explains the key steps and shows the integration code that matters. The repository is the "full runnable reference".

Project setup​

In this section you will prepare Google OAuth credentials, configure the project, and run the app locally.

1) Get the project code​

Do one of the following:

  • Clone the repository:
Terminal
git clone https://github.com/DHTMLX/scheduler-google-calendar-demo.git
cd scheduler-google-calendar-demo

If your project installs @dhx/* packages from the private registry, configure npm:

Terminal
npm config set @dhx:registry https://npm.dhtmlx.com

2) Configure Google Cloud (OAuth 2.0)​

In this step you will create OAuth credentials that the backend uses to access Google Calendar on behalf of a user.

The guide uses OAuth in Testing mode (recommended for development). In this mode, only users listed as Test users can authorize the app.

2.1 Create or select a Google Cloud project​

  1. Open Google Cloud Console.
  2. Select an existing project or create a new one.

2.2 Enable Google Calendar API​

  1. Go to APIs & Services → Library.
  2. Search for Google Calendar API.
  3. Click Enable.
  1. Go to APIs & Services → OAuth consent screen.
  2. Choose External (typical for consumer Google accounts), then click Create.
  3. Fill in the required fields:
    • App name
    • User support email
    • Developer contact email
  4. Set Publishing status to Testing.
  5. Add Test users:
    • Add the Google accounts you will use to sign in while developing/testing.
note

If you skip test users in Testing mode, Google will block authorization for accounts that are not explicitly added.

2.4 Create OAuth client credentials​

  1. Go to APIs & Services → Credentials.
  2. Click Create credentials → OAuth client ID.
  3. Application type: Web application.
  4. Add this Authorized JavaScript origin:
JavaScript origin
http://localhost:3000
  1. Add this Authorized redirect URI:
Redirect URI
http://localhost:3000/auth/google/callback
  1. Save and copy:
    • Client ID
    • Client Secret

2.5 Scope used by this integration​

The backend requests Google Calendar access via:

  • https://www.googleapis.com/auth/calendar

This scope is sufficient for listing calendars and performing event CRUD operations.

3) Configure environment variables​

In this step you will provide OAuth credentials and session settings to the backend.

Copy .env.example to .env, then fill in the values:

.env
GOOGLE_CLIENT_ID=<Client ID from the previous steps>
GOOGLE_CLIENT_SECRET=<Client Secret from the previous steps>
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
SESSION_SECRET=some-long-random-string
PORT=3000

4) Install dependencies and run​

Terminal
npm install
npm run start

Open:

App URL
http://localhost:3000

At this point you should be able to click Add Google Calendars, sign in, and see Scheduler populated with events.


Implementation​

The rest of the guide explains how the integration is put together. If you are adapting this to an existing app, treat each section below as an implementation milestone.

Step 1 - Split responsibilities (backend vs client)​

In this step you will separate responsibilities so Scheduler stays a UI component and the backend owns OAuth + Google API calls.

A typical structure:

Project structure
scheduler-google-calendar-demo/
client/
index.ejs
main.ts
styles.css
server/
config/
index.ts
passport.ts
routes/
auth.route.ts
events.route.ts
services/
googleService.ts
mappers/
eventMapper.ts
rollup.config.js
package.json
.env.example
  • server/: OAuth, token storage (in session), Google Calendar API calls, and REST endpoints for Scheduler
  • client/: Scheduler init + loading, and DataProcessor that forwards CRUD actions to the server

Step 2 - Implement Google OAuth (Express session + Passport)​

In this step you will make the backend able to authenticate a user and store Google access/refresh tokens.

2.1 Bootstrap the server (sessions + passport)​

Update server/index.ts to enable sessions and passport, then mount your routes.

Below is the core wiring (only the relevant parts are shown):

server/index.ts
app.use(
session({
secret: config.SESSION_SECRET || "fallback-secret-for-dev",
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
sameSite: "lax",
},
})
);

app.use(passport.initialize());
app.use(passport.session());

app.use("/events", (req, res, next) => {
req.isAuthenticated() ? next() : res.status(401).json({ error: "Not authenticated" });
}, eventsRoute);
app.use("/auth", authRoute);

app.get("/", (req, res) => {
res.render("index", { googleAuth: req.isAuthenticated() });
});

The inline middleware on /events ensures unauthenticated requests receive a 401 response instead of crashing the server when route handlers cast req to AuthenticatedRequest.

2.2 Configure the Google strategy​

Update server/config/passport.ts to register passport-google-oauth20.

The key idea: keep accessToken and refreshToken on the user object stored in the session:

server/config/passport.ts
passport.serializeUser((user: Express.User, done) => {
done(null, user);
});

passport.deserializeUser((obj: Express.User, done) => {
done(null, obj);
});

passport.use(
new GoogleStrategy(
{
clientID: config.GOOGLE_CLIENT_ID || "",
clientSecret: config.GOOGLE_CLIENT_SECRET || "",
callbackURL: config.GOOGLE_REDIRECT_URI || "",
},
(accessToken, refreshToken, profile, done) => {
const user = {
id: profile.id,
displayName: profile.displayName,
tokens: { accessToken, refreshToken },
};
done(null, user as unknown as Express.User);
}
)
);
note

Production apps usually persist tokens per user in a database and implement refresh token rotation/revocation. This example keeps tokens in-session to keep the flow easy to follow.

2.3 Add OAuth routes​

Update server/routes/auth.route.ts to expose the OAuth entry point, callback, and logout:

server/routes/auth.route.ts
router.get(
"/google",
passport.authenticate("google", {
scope: ["profile", "email", "https://www.googleapis.com/auth/calendar"],
accessType: "offline",
prompt: "consent",
})
);

router.get(
"/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
(_req, res) => res.redirect("/")
);

router.get("/google/logout", (req, res, next) => {
req.logout((err) => (err ? next(err) : res.redirect("/")));
});

At this point you should be able to hit /auth/google, complete the Google consent screen, and return to / with an authenticated session.

Create the Google Calendar service layer​

Create server/services/googleService.ts to wrap Google Calendar API v3 CRUD methods. It creates an OAuth2 client from the session tokens and exposes helpers for listing calendars, listing events, and creating/updating/deleting events:

server/services/googleService.ts
import { google, calendar_v3 } from "googleapis";
import type { GoogleOAuthTokens } from "../types/auth.types.ts";
import config from "../config/index.ts";

const calendarClient = google.calendar("v3");

function oauthClient(tokens: GoogleOAuthTokens) {
const client = new google.auth.OAuth2(
config.GOOGLE_CLIENT_ID,
config.GOOGLE_CLIENT_SECRET,
config.GOOGLE_REDIRECT_URI
);
client.setCredentials({
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
});
return client;
}

/* ------ CRUD helpers ------- */

export async function listCalendars(tokens: GoogleOAuthTokens): Promise<calendar_v3.Schema$CalendarListEntry[]> {
const { data } = await calendarClient.calendarList.list({ auth: oauthClient(tokens) });
return data.items ?? [];
}

export async function listEvents(
tokens: GoogleOAuthTokens,
opts: calendar_v3.Params$Resource$Events$List
): Promise<calendar_v3.Schema$Event[]> {
const { data } = await calendarClient.events.list({
auth: oauthClient(tokens),
...opts,
});
return data.items ?? [];
}

export async function createEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
gEvent: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.insert({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
requestBody: gEvent,
conferenceDataVersion: 1,
});
return data;
}

export async function updateEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
eventId: string,
gPatch: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.patch({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
eventId,
requestBody: gPatch,
});
return data;
}

export async function deleteEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
eventId: string
): Promise<void> {
await calendarClient.events.delete({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
eventId,
});
}

Step 3 - Expose a REST API for Scheduler CRUD​

In this step you will implement the API contract Scheduler uses:

  • GET /events - load calendars + events
  • POST /events - create
  • PUT /events/:eventId - update
  • DELETE /events/:eventId - delete

3.1 Load calendars + events (GET /events)​

Update server/routes/events.route.ts to return:

  • data containing Scheduler-style events
  • collections.calendars containing list of calendars that will be available on the client

Below is a working example handler:

server/routes/events.route.ts
router.get("/", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;

try {
const calendars = await googleService.listCalendars(authedReq.user.tokens);

const mappedCals = calendars
.filter((calendar) => Boolean(calendar.id))
.map((calendar) => ({
id: calendar.id as string,
key: calendar.id as string,
label: calendar.summary ?? "",
backgroundColor: calendar.backgroundColor ?? undefined,
}));

const fromQuery = typeof req.query.from === "string" ? req.query.from : undefined;
const toQuery = typeof req.query.to === "string" ? req.query.to : undefined;

const minDate = fromQuery ? new Date(fromQuery).toISOString() : new Date().toISOString();
const maxDate = toQuery ? new Date(toQuery).toISOString() : undefined;

const googleEvents = await Promise.all(
mappedCals.map(async (calendar) => {
const params: calendar_v3.Params$Resource$Events$List = { calendarId: calendar.id, timeMin: minDate };
if (maxDate) params.timeMax = maxDate;

const calendarEventsResponse = await googleService.listEvents(authedReq.user.tokens, params);

return (calendarEventsResponse as Array<Record<string, unknown>>).map((event) =>
toDhxEvent(event as calendar_v3.Schema$Event, calendar)
);
})
);

res.json({
success: true,
data: googleEvents.flat(),
collections: { calendars: mappedCals },
});
} catch (error) {
next(error);
}
});

Related docs: Loading data.

3.2 Forward CRUD operations to Google Calendar​

Update the same route module to handle create/update/delete.

Create:

server/routes/events.route.ts
router.post("/", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
const calendarId = (req.body as DhxEvent)?.calendarId as string | undefined;

try {
const gEvent = await googleService.createEvent(
authedReq.user.tokens,
calendarId,
toGoogleEventPayload(req.body)
);

res.status(201).json({ action: "inserted", tid: gEvent.id });
} catch (error) {
next(error);
}
});

Update:

server/routes/events.route.ts
router.put("/:eventId", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
const calendarId = (req.body as DhxEvent)?.calendarId as string | undefined;

try {
const gEvent = await googleService.updateEvent(
authedReq.user.tokens,
calendarId,
req.params.eventId as string,
toGoogleEventPayload(req.body)
);

res.json({ action: "updated", tid: gEvent.id });
} catch (error) {
next(error);
}
});

Delete:

server/routes/events.route.ts
router.delete("/:eventId", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
const calendarId = (req.body as DhxEvent)?.calendarId as string | undefined;

// If this is an exception occurrence (id contains "_"), there is nothing to delete on Google side.
// Google Calendar removes occurrences when deleting the main recurring event.
const dhxId = req.body?.id as string | undefined;
if (typeof dhxId === "string" && dhxId.indexOf("_") > -1) {
res.json({ action: "deleted" });
return;
}

try {
await googleService.deleteEvent(authedReq.user.tokens, calendarId, req.params.eventId as string);
res.json({ action: "deleted" });
} catch (error) {
next(error);
}
});

At this point Scheduler can load /events, and basic CRUD can be wired on the client.

Step 4 - Map Google events to Scheduler events (and back)​

In this step you will implement a mapper that converts between:

  • Google event fields (start.dateTime / start.date, recurrence, etc.)
  • Scheduler event fields (start_date, end_date, rrule, etc.)

Key differences you must handle​

  1. All-day vs timed events
  • Google: all-day uses start.date / end.date
  • Google: timed uses start.dateTime / end.dateTime and may include timeZone
  • Scheduler: uses start_date / end_date (Date objects)
  1. Recurrence rules
  • Google stores recurrence as array strings with RRULE: prefix
  • Scheduler uses rrule without the prefix
  1. Recurring series end date
  • Scheduler expects an end_date for recurring series.
  • Google may use UNTIL= in RRULE, or no UNTIL (infinite series).

Related docs: Recurring events.

Google → Scheduler​

Update server/mappers/eventMapper.ts to map the Google event shape into Scheduler's event shape (excerpt below; keep helper functions like calculateEndDate() in the same module):

server/mappers/eventMapper.ts
import moment from "moment-timezone";
import type { DhxEvent, MappedCalendar } from "../types/types.ts";
import type { calendar_v3 } from "googleapis";

export function toDhxEvent(gEvent: calendar_v3.Schema$Event, calendar: MappedCalendar): DhxEvent {
const ev: DhxEvent = {
id: gEvent.id as string,
text: gEvent.summary ?? "",
details: gEvent.description ?? "",
calendarId: calendar.id,
calendarLabel: calendar.label,
timeZone: gEvent.start?.timeZone,
recurring_event_id: gEvent.recurringEventId,
status: gEvent.status,
deleted: gEvent.status === "cancelled",
};

const start = gEvent.start;
const end = gEvent.end;

// Non-recurring
if (start?.dateTime && end?.dateTime && !gEvent.recurrence?.length) {
ev.start_date = new Date(start.dateTime);
ev.end_date = new Date(end.dateTime);
} else if (start?.date && end?.date && !gEvent.recurrence?.length) {
ev.start_date = new Date(start.date + "T00:00:00");
ev.end_date = new Date(end.date + "T00:00:00");
}

// Recurring
if (gEvent.recurrence?.length) {
ev.rrule = String(gEvent.recurrence[0]).replace("RRULE:", "");

if (start?.dateTime && end?.dateTime) {
ev.start_date = new Date(start.dateTime);
ev.duration = (new Date(end.dateTime).getTime() - ev.start_date.getTime()) / 1000;
} else if (start?.date && end?.date) {
ev.start_date = new Date(start.date + "T00:00:00");
ev.duration = (new Date(end.date).getTime() - ev.start_date.getTime()) / 1000;
}

ev.end_date = calculateEndDate(gEvent);
}

// Exceptions: original start time
if (gEvent.originalStartTime?.dateTime) {
ev.original_start = new Date(gEvent.originalStartTime.dateTime);
}

return ev;
}

calculateEndDate() helper​

Extracts the UNTIL date from a Google recurrence rule, or returns a far-future date for infinite series. Scheduler expects an end_date on recurring events, so this helper provides one:

server/mappers/eventMapper.ts
// convert UNTIL=20260129T205959Z -> '2026-01-29T20:59:59Z' if it exists
// if there is no UNTIL -> event repeat infinitely -> return '9999-02-01T00:00:00Z'
function calculateEndDate(gEvent: calendar_v3.Schema$Event): Date {
const until = String(gEvent.recurrence?.[0] ?? "").match(/RRULE:.*?UNTIL=([^;]+)/)?.[1];

return until
? new Date(
until.replace(/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z$/, "$1-$2-$3T$4:$5:$6Z")
)
: new Date(9999, 1, 1);
}

Scheduler → Google​

Update the mapper to convert Scheduler event fields back into Google's schema:

server/mappers/eventMapper.ts
export function toGoogleEventPayload(dhx: DhxEvent): calendar_v3.Schema$Event {
const gEvent: calendar_v3.Schema$Event = {
summary: dhx.text,
description: dhx.details ?? "",
};

const tz = dhx.timeZone || "UTC";

if (dhx.start_date && dhx.end_date) {
const isAllDay =
dhx.duration === 24 * 60 * 60 ||
new Date(dhx.end_date).getTime() - new Date(dhx.start_date).getTime() === 24 * 60 * 60 * 1000;

// Recurring
if (dhx.rrule && dhx.duration) {
gEvent.recurrence = ["RRULE:" + dhx.rrule];

if (isAllDay) {
gEvent.start = { date: moment(dhx.start_date).format("YYYY-MM-DD") };
gEvent.end = { date: moment(dhx.start_date).add(1, "day").format("YYYY-MM-DD") };
} else {
gEvent.start = {
dateTime: moment(new Date(dhx.start_date).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};

const endDate = new Date(new Date(dhx.start_date).getTime() + dhx.duration * 1000).toISOString();
gEvent.end = {
dateTime: moment(endDate).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
}
} else {
// Non-recurring
if (isAllDay) {
gEvent.start = { date: moment(dhx.start_date).format("YYYY-MM-DD") };
gEvent.end = { date: moment(dhx.start_date).add(1, "day").format("YYYY-MM-DD") };
} else {
gEvent.start = {
dateTime: moment(new Date(dhx.start_date).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
gEvent.end = {
dateTime: moment(new Date(dhx.end_date).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
}
}

// Recurring exceptions support
if (dhx.recurring_event_id) {
gEvent.recurringEventId = dhx.recurring_event_id.toString();
}
if (dhx.original_start) {
gEvent.originalStartTime = {
dateTime: moment(new Date(dhx.original_start).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
}
if (dhx.deleted) {
gEvent.status = "cancelled";
}
}

return gEvent;
}

Step 5 - Wire Scheduler to the backend (load + CRUD)​

In this step you will initialize Scheduler, load data from GET /events, and send CRUD operations to the backend via DataProcessor.

5.1 Render a different UI for "authorized vs not authorized"​

Update client/index.ejs to expose an authorization flag to the client:

client/index.ejs
<script>
const GOOGLE_AUTHORIZED = <%= !!googleAuth %>;
</script>

5.2 Initialize Scheduler and load events​

Update client/main.ts to initialize Scheduler and load data once the user is authorized.

Only the relevant part is shown:

client/main.ts
scheduler.plugins({ recurring: true });
scheduler.config.header = ["day", "week", "month", "date", "prev", "today", "next"];

scheduler.init("scheduler_here", new Date(), "week");
scheduler.setLoadMode("week");

if (GOOGLE_AUTHORIZED) {
scheduler.load("/events");
} else {
window.alert("You must authorize Google Calendar to use this app.");
}

5.3 Enable two-way sync with DataProcessor​

Update client/main.ts to forward Scheduler CRUD actions to the server.

client/main.ts
scheduler.createDataProcessor(async (entity, action, data, id) => {
const calendars = scheduler.serverList("calendars") as MappedCalendar[];

// Demo simplification: send everything into the first available calendar.
// In a real app, let users choose a target calendar.
data.calendarId = calendars[0]?.id;

// Provide client timezone so the server can generate correct dateTime values.
data.timeZone = momentTz.tz.guess();

return fetchEvent(action, data, id);
});

async function fetchEvent(action, data, id) {
const requestConfigs = {
create: ["POST", "events/", data],
update: ["PUT", `events/${id}`, data],
delete: ["DELETE", `events/${id}`, data],
};

const [method, url, payload] = requestConfigs[action];

const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json;charset=utf-8" },
body: JSON.stringify(payload),
});

return response.json();
}

Related docs: DataProcessor.

At this point:

  • events from Google Calendar should appear in Scheduler after authorization
  • creating/updating/deleting in Scheduler should update Google Calendar

Troubleshooting​

"Error 400: redirect_uri_mismatch"​

  • Cause: The redirect URI in Google Cloud credentials does not match your app callback URL.
  • Fix: Ensure the Authorized redirect URI is exactly:
    • http://localhost:3000/auth/google/callback

"Access blocked: app has not completed the Google verification process"​

  • Cause: Consent screen is not in Testing mode or you are not listed as a test user.
  • Fix: Set Publishing status to Testing and add your account in Test users.

"No refresh token returned"​

  • Cause: Google may return a refresh token only the first time the user consents for a given client ID.
  • Fix: Ensure your auth request includes accessType: "offline" and prompt: "consent". If you already authorized before, revoke access in Google Account permissions and authorize again.

Summary​

You implemented two-way sync between Scheduler and Google Calendar:

  • The backend authenticates users via Google OAuth 2.0 and stores tokens in the session
  • Scheduler loads calendars and events through GET /events
  • Scheduler CRUD operations are forwarded to Google Calendar via POST/PUT/DELETE /events
  • A mapper converts timed/all-day and recurring events between Google Calendar and Scheduler

Full working source:

Need help?
Got a question about the documentation? Reach out to our technical support team for help and guidance. For custom component solutions, visit the Services page.