Перейти к основному содержимому

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
примечание

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

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-auth-demo.git
cd scheduler-google-auth-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.
примечание

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 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=...
GOOGLE_CLIENT_SECRET=...
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-auth-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", eventsRoute);
app.use("/auth", authRoute);

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

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.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);
}
)
);
примечание

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.

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]](/guides/loading-data)

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: Record<string, unknown> = { 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
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;
}

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 ?? "",
};

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
.tz(new Date(dhx.start_date).toISOString(), "YYYY-MM-DD HH:mm", dhx.timeZone)
.format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: dhx.timeZone,
};

const endDate = new Date(new Date(dhx.start_date).getTime() + dhx.duration * 1000).toISOString();
gEvent.end = {
dateTime: moment.tz(endDate, "YYYY-MM-DD HH:mm", dhx.timeZone).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: dhx.timeZone,
};
}
} 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
.tz(new Date(dhx.start_date).toISOString(), "YYYY-MM-DD HH:mm", dhx.timeZone)
.format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: dhx.timeZone,
};
gEvent.end = {
dateTime: moment
.tz(new Date(dhx.end_date).toISOString(), "YYYY-MM-DD HH:mm", dhx.timeZone)
.format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: dhx.timeZone,
};
}
}

// Recurring exceptions support
if (dhx.recurring_event_id) {
gEvent.recurringEventId = dhx.recurring_event_id.toString();
}
if (dhx.original_start) {
gEvent.originalStartTime = {
dateTime: moment
.tz(new Date(dhx.original_start).toISOString(), "YYYY-MM-DD HH:mm", dhx.timeZone)
.format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: dhx.timeZone,
};
}
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 (replace with your real repo):

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.