A marketing site can route every call to an available teammate while keeping assignment rules, calendar availability, and visitor details inside booking code you control on Webflow Cloud.
When you assign a single booking link to one individual, it works smoothly. However, once you share responsibilities across a sales or support team, incoming booking requests still default to a single calendar. This forces someone to check individual availability and manually distribute calls one by one.
In this build, we move that routing into code. We’ll build a Next.js app mounted at a path on your Webflow site, so visitors book without leaving your domain. It checks every teammate's Google Calendar in one request and offers only the slots at least one of them has free.
When a visitor books, it writes the event to the calendar of whoever takes the slot, rotating among free teammates so one person doesn't get every call.
What do you need to build multi-user Google Calendar booking in Webflow?
You need six prerequisites:
- Webflow site: Any plan works once the site supports Webflow Cloud; a custom domain needs Premium or higher.
- Google Cloud project: You need rights to allow Google Calendar API access and create a service account with a JSON key.
- Calendar sharing: Each teammate must agree to share their Google Calendar with one service account email address.
- Node.js and npm: A Webflow Cloud-compatible Node.js release; Webflow Cloud builds with npm only, so every install command uses
npm install. - Next.js: A Webflow Cloud-compatible Next.js release; create-next-app@latest installs a compatible release, and older projects may need an upgrade first.
- App code: The app's code, ready to deploy to Webflow Cloud using the method Webflow's developer docs describe for bring-your-own apps.
With these six prerequisites ready, you can build the authentication, availability, booking, and deployment paths in order.
6 steps to build multi-user Google Calendar booking in Webflow Cloud
Build the booking flow in six stages: authorize shared calendars, scaffold the app, mint Google tokens, calculate availability, create bookings, and mount the finished page on your Webflow Cloud site.
/api/availability merges free/busy across every teammate into bookable slots, while /api/book handles assignment and event creation.
1. Create the service account and share the calendars
Allow Google Calendar API access in your Google Cloud project, create a service account, leave its project roles empty, and download a JSON key for it. Name the account something like team-booking.
Calendar access comes from sharing each calendar with the account's email address. Google's service account documentation walks through creating the account and its key.
The downloaded file contains client_email and private_key, which are the only two fields the app uses. Both feed the token helper in lib/google.ts. The Keys tab lists the new key, and the client_email on the account is the address you share calendars with.
Now share each teammate's calendar with that client_email. In each teammate's Google Calendar sharing settings, add the service account address with the Make changes to events permission. Read-only sharing would let the availability route work and the booking route fail, which is a confusing place to discover the mistake.
Note each calendar's ID from its settings page; you list these IDs in BOOKING_CALENDARS. You now have a JSON key on disk and a list of calendar IDs, and each teammate's sharing list shows the service account address with edit rights.
2. Scaffold the Next.js app and store the Google credentials
Use npm to generate the App Router and TypeScript project.
Run the scaffold from an empty directory:
npx create-next-app@latest team-booking --typescript --app --eslint --no-src-dir --no-tailwind --import-alias "@/*"
cd team-booking
The flags answer the main prompts and keep the app/ and @/lib paths the code uses. The project needs no dependencies beyond what the generator installs.
Every configuration value lives in environment variables so the same code runs locally and on Webflow Cloud.
Webflow Cloud makes both secret and non-secret variables available to the build process and the deployed app at runtime, and redacts secrets from build logs so that you can store the private key as a secret environment variable. Locally, the same values go in .env.local.
The variables the app reads, with the shape each one expects:
| Variable | Value | Notes |
|---|---|---|
GOOGLE_CLIENT_EMAIL |
The client_email from the JSON key |
Also used as the JWT iss claim |
GOOGLE_PRIVATE_KEY |
The private_key from the JSON key |
Paste as one line with the literal \n sequences intact |
BOOKING_CALENDARS |
Ana=ana@example.com,Ben=ben@example.com |
Display name, =, calendar ID; comma separated |
BOOKING_TIMEZONE |
An IANA zone such as America/New_York |
Working hours are evaluated in this zone |
BOOKING_START_HOUR |
9 |
First bookable hour, 24-hour clock |
BOOKING_END_HOUR |
17 |
Slots must end by this hour |
NEXT_PUBLIC_BASE_PATH |
Empty locally; the mount path (for example /book) on Webflow Cloud |
Prefixes every client-side fetch |
| Variable → Value → Notes |
|---|
GOOGLE_CLIENT_EMAIL |
The client_email from the JSON key |
Also used as the JWT iss claim |
GOOGLE_PRIVATE_KEY |
The private_key from the JSON key |
Paste as one line with the literal \n sequences intact |
BOOKING_CALENDARS |
Ana=ana@example.com,Ben=ben@example.com |
Display name, =, calendar ID; comma separated |
BOOKING_TIMEZONE |
An IANA zone such as America/New_York |
| Working hours are evaluated in this zone |
BOOKING_START_HOUR |
9 |
| First bookable hour, 24-hour clock |
BOOKING_END_HOUR |
17 |
| Slots must end by this hour |
NEXT_PUBLIC_BASE_PATH |
Empty locally; the mount path (for example /book) on Webflow Cloud |
| Prefixes every client-side fetch |
A working .env.local looks like this, with your own values substituted:
GOOGLE_CLIENT_EMAIL=team-booking@your-project.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----\n"
BOOKING_CALENDARS=Ana=ana@example.com,Ben=ben@example.com,Chloe=chloe@example.com
BOOKING_TIMEZONE=America/New_York
BOOKING_START_HOUR=9
BOOKING_END_HOUR=17
NEXT_PUBLIC_BASE_PATH=
Add .env.local to .gitignore if the generator did not, then run npm run dev. The default Next.js page renders at http://localhost:3000, which confirms the toolchain before any Google code enters the picture.
3. Mint a Google access token with Web Crypto
The Workers runtime reads the private key from an environment variable. The token helper uses a plain fetch against Google's token endpoint and signs the service-account JWT (JSON Web Token) with crypto.subtle.
The private key is imported as PKCS#8, the JWT is signed with RS256, and the signed assertion is exchanged for a bearer token using the jwt-bearer grant. Google's service account documentation describes this JWT bearer flow. PKCS#8 is the private-key format in the JSON file; RS256 is RSA signing with SHA-256.
The token helper lives in lib/google.ts:
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
// Only the two calls this app makes: free/busy reads and event inserts.
const SCOPE = 'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.freebusy';
let cached: { token: string; expiresAt: number } | null = null;
function base64url(input: ArrayBuffer | string): string {
const bytes =
typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function importPrivateKey(pem: string): Promise<CryptoKey> {
const body = pem
.replace(/\\n/g, '\n')
.replace('-----BEGIN PRIVATE KEY-----', '')
.replace('-----END PRIVATE KEY-----', '')
.replace(/\s+/g, '');
const raw = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
'pkcs8',
raw,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
);
}
export async function getAccessToken(): Promise<string> {
if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token;
const clientEmail = process.env.GOOGLE_CLIENT_EMAIL;
const privateKey = process.env.GOOGLE_PRIVATE_KEY;
if (!clientEmail || !privateKey) {
throw new Error('GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY must be set');
}
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claims = base64url(
JSON.stringify({ iss: clientEmail, scope: SCOPE, aud: TOKEN_URL, iat: now, exp: now + 3600 }),
);
const unsigned = `${header}.${claims}`;
const key = await importPrivateKey(privateKey);
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
key,
new TextEncoder().encode(unsigned),
);
const assertion = `${unsigned}.${base64url(signature)}`;
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!res.ok) {
throw new Error(`Google token request failed: ${res.status} ${await res.text()}`);
}
const data = (await res.json()) as { access_token: string; expires_in: number };
cached = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
return cached.token;
}
The .replace(/\\n/g, '\n') line is the one that saves you later: the key arrives from an environment variable as a single line containing literal backslash-n pairs, and the PEM parser needs real newlines.
The module-level cached object may let a warm instance reuse a token; when it is empty, the helper requests a new one. Both routes use the same token and the calendar scope set in SCOPE.
Run npx tsc --noEmit; it exits with no errors, and the helper is ready for the routes that call it.
4. Build the availability Route Handler
One free/busy request covers every calendar and provides the busy blocks needed to generate open slots with the names of whoever is free in each. Google's free/busy query endpoint accepts up to 50 calendar IDs and returns busy ranges per calendar, so one request covers the whole roster in BOOKING_CALENDARS.
The reference documents a per-calendar errors field, such as notFound, for any calendar it could not read. Ignore that field, and a teammate who never shared their calendar comes back with no busy blocks and appears free in every slot, so both routes below drop any calendar that reports an error.
Start with the Calendar API wrapper at lib/calendar.ts, which app/api/book/route.ts reuses:
import { getAccessToken } from './google';
export type Teammate = { name: string; calendarId: string };
export type Busy = { start: string; end: string };
export type CalendarEntry = { busy: Busy[]; errors?: { domain: string; reason: string }[] };
const CALENDAR_API = 'https://www.googleapis.com/calendar/v3';
export function teammates(): Teammate[] {
return (process.env.BOOKING_CALENDARS ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [name, calendarId] = entry.split('=');
return { name: name.trim(), calendarId: calendarId.trim() };
});
}
async function authedHeaders() {
const token = await getAccessToken();
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
export async function freeBusy(
timeMin: string,
timeMax: string,
calendarIds: string[],
): Promise<Record<string, CalendarEntry>> {
const res = await fetch(`${CALENDAR_API}/freeBusy`, {
method: 'POST',
headers: await authedHeaders(),
body: JSON.stringify({ timeMin, timeMax, items: calendarIds.map((id) => ({ id })) }),
});
if (!res.ok) throw new Error(`freeBusy failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { calendars: Record<string, CalendarEntry> };
return data.calendars;
}
export async function createEvent(calendarId: string, event: Record<string, unknown>) {
const res = await fetch(
`${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`,
{ method: 'POST', headers: await authedHeaders(), body: JSON.stringify(event) },
);
if (!res.ok) throw new Error(`events.insert failed: ${res.status} ${await res.text()}`);
return (await res.json()) as { id: string; htmlLink?: string };
}
Both API calls now share one token path and one error shape, and the environment variable supplies the roster.
For each candidate slot endpoint, the code asks Intl.DateTimeFormat for the local time and weekday in BOOKING_TIMEZONE, which stays correct across daylight-saving changes.
Put the slot logic in lib/availability.ts:
import type { Busy, CalendarEntry, Teammate } from './calendar';
export const SLOT_MS = 30 * 60 * 1000;
export type Slot = { start: string; end: string; available: string[] };
function localParts(date: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hour: 'numeric',
minute: 'numeric',
hour12: false,
weekday: 'short',
}).formatToParts(date);
const rawHour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0);
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0);
const weekday = parts.find((p) => p.type === 'weekday')?.value ?? '';
return { hour: rawHour === 24 ? 0 : rawHour, minute, weekday };
}
function overlaps(start: number, end: number, busy: Busy[]) {
return busy.some((b) => start < Date.parse(b.end) && end > Date.parse(b.start));
}
export function isBookableSlot(startMs: number): boolean {
if (startMs % SLOT_MS !== 0) return false;
const timeZone = process.env.BOOKING_TIMEZONE ?? 'UTC';
const startHour = Number(process.env.BOOKING_START_HOUR ?? 9);
const endHour = Number(process.env.BOOKING_END_HOUR ?? 17);
const start = localParts(new Date(startMs), timeZone);
const end = localParts(new Date(startMs + SLOT_MS), timeZone);
if (start.weekday === 'Sat' || start.weekday === 'Sun') return false;
if (end.weekday === 'Sat' || end.weekday === 'Sun') return false;
const startMinutes = start.hour * 60 + start.minute;
const endMinutes = end.hour * 60 + end.minute;
return startMinutes >= startHour * 60 && endMinutes <= endHour * 60;
}
export function openSlots(
timeMin: string,
timeMax: string,
team: Teammate[],
calendars: Record<string, CalendarEntry>,
): Slot[] {
const first = Math.ceil(Date.parse(timeMin) / SLOT_MS) * SLOT_MS;
const last = Date.parse(timeMax);
const slots: Slot[] = [];
for (let t = first; t + SLOT_MS <= last; t += SLOT_MS) {
if (!isBookableSlot(t)) continue;
const available = team
.filter((m) => !overlaps(t, t + SLOT_MS, calendars[m.calendarId]?.busy ?? []))
.map((m) => m.name);
if (available.length > 0) {
slots.push({
start: new Date(t).toISOString(),
end: new Date(t + SLOT_MS).toISOString(),
available,
});
}
}
return slots;
}
Each slot now carries the list of teammates free during it, which the booking route uses for assignment, and the client can use to show how many people are available. Weekends are skipped in code, with no environment variable controlling them, and slots align to fixed UTC boundaries, so zones with offset increments outside that boundary can get shifted slots.
Calculating bookable time slots
The Route Handler drops any teammate whose calendar returns errors. It also rejects reversed or unsupported timestamp ranges and caps the requested window at the limit defined in the route so a visitor cannot request an excessively large set of slots in one call.
The availability route at app/api/availability/route.ts ties the two modules together:
import { NextResponse } from 'next/server';
import { freeBusy, teammates } from '@/lib/calendar';
import { openSlots } from '@/lib/availability';
const MAX_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
function parseIsoUtc(value: string | null): number | null {
if (!value || !ISO_UTC.test(value)) return null;
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return null;
const expected = value.includes('.') ? value : value.replace('Z', '.000Z');
return new Date(timestamp).toISOString() === expected ? timestamp : null;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const timeMin = searchParams.get('timeMin');
const timeMax = searchParams.get('timeMax');
const minMs = parseIsoUtc(timeMin);
const maxMs = parseIsoUtc(timeMax);
if (timeMin === null || timeMax === null || minMs === null || maxMs === null || maxMs <= minMs) {
return NextResponse.json(
{ error: 'timeMin and timeMax must be increasing ISO 8601 UTC timestamps' },
{ status: 400 },
);
}
if (maxMs - minMs > MAX_WINDOW_MS) {
return NextResponse.json({ error: 'Request at most 14 days at a time' }, { status: 400 });
}
const team = teammates();
const calendars = await freeBusy(timeMin, timeMax, team.map((m) => m.calendarId));
const reachable = team.filter((m) => !calendars[m.calendarId]?.errors?.length);
const slots = openSlots(timeMin, timeMax, reachable, calendars);
return NextResponse.json({ slots, unreachable: team.length - reachable.length });
}
With npm run dev running, open http://localhost:3000/api/availability?timeMin=2026-10-05T00:00:00Z&timeMax=2026-10-07T00:00:00Z in a browser.
A JSON body with a slots array of fixed-duration windows inside working hours and no unreachable calendars confirms that the complete availability request path works. If the response reports unreachable calendars, an entry in BOOKING_CALENDARS has not been shared with the service account yet.
5. Build the booking Route Handler
The availability list a visitor sees can be minutes old, and a teammate may have accepted a meeting in the meantime. The booking route runs its own free/busy query for just the requested range before it creates the event on one teammate's calendar.
Any teammate still free is eligible for assignment; if none are free, the route returns 409, and the client can refresh the list. Google's event insertion call takes the event body directly.
The re-check narrows the window but does not close it. Two visitors who submit the same slot at the same moment get the same free/busy answer. Because the host is picked by position rather than at random, both requests choose the same teammate: a guaranteed double booking, not an occasional one.
For low traffic, that is a rare edge case you can fix by hand. For anything busier, record each booking in Webflow Cloud SQLite with a unique constraint on the host and slot start, and insert that row before calling Google, so the second request fails at the database instead of on someone's calendar.
The route divides the slot's start time by the slot length and takes that number modulo the count of free teammates, so consecutive slots rotate through the team instead of always landing on the first name in the list. A true fair-share count would need storage; the modulo spreads morning and afternoon slots across different people with no state.
Routing and creating the booking event
Create app/api/book/route.ts:
import { NextResponse } from 'next/server';
import { createEvent, freeBusy, teammates } from '@/lib/calendar';
import { SLOT_MS, isBookableSlot } from '@/lib/availability';
type BookingBody = { start: string; end: string; name: string; email: string; notes?: string };
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as BookingBody | null;
if (!body?.start || !body?.end || !body?.name || !body?.email) {
return NextResponse.json(
{ error: 'start, end, name and email are required' },
{ status: 400 },
);
}
const startMs = Date.parse(body.start);
const endMs = Date.parse(body.end);
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs || startMs < Date.now()) {
return NextResponse.json({ error: 'Invalid or past time range' }, { status: 400 });
}
if (endMs - startMs !== SLOT_MS) {
return NextResponse.json({ error: 'Bookings must be exactly one slot long' }, { status: 400 });
}
if (!isBookableSlot(startMs)) {
return NextResponse.json({ error: 'That time is outside bookable hours' }, { status: 400 });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
return NextResponse.json({ error: 'Enter a valid email address' }, { status: 400 });
}
const team = teammates();
const calendars = await freeBusy(body.start, body.end, team.map((m) => m.calendarId));
const free = team.filter((m) => {
const entry = calendars[m.calendarId];
return entry && !entry.errors?.length && entry.busy.length === 0;
});
if (free.length === 0) {
return NextResponse.json({ error: 'That slot was just taken' }, { status: 409 });
}
const host = free[Math.floor(startMs / SLOT_MS) % free.length];
const timeZone = process.env.BOOKING_TIMEZONE ?? 'UTC';
const event = await createEvent(host.calendarId, {
summary: `Intro call with ${body.name}`,
description: [
'Booked from the website.',
`Name: ${body.name}`,
`Email: ${body.email}`,
body.notes ? `Notes: ${body.notes}` : '',
].join('\n'),
start: { dateTime: new Date(startMs).toISOString(), timeZone },
end: { dateTime: new Date(endMs).toISOString(), timeZone },
});
return NextResponse.json({ host: host.name, eventId: event.id });
}
The route handles validation and event creation, with a free/busy re-check immediately before the write. The visitor's name and email go in the event description; the event has no attendees, so Google sends the visitor no invite, and the code sends no confirmation. That is deliberate.
Adding the visitor as an attendee is the obvious improvement, but whether a plain service account can invite people depends on domain-wide delegation, which this build avoids; test it against your own account before relying on it.
The most reliable way to confirm the booking to the visitor is via your own email, for example, with Postmark on Webflow Cloud.
Both routes accept unauthenticated requests, and the booking route writes to real calendars. Treat this implementation as a private test build and keep it private until bot protection and rate limits are implemented.
A public deployment also needs quota controls that stop abusive traffic. The route already rejects any start outside working hours, on a weekend, or off a slot boundary, and any malformed email, before it calls createEvent.
Testing the booking route with cURL
Test it with curl against a returned availability slot after replacing the timestamps with real ones:
curl -X POST http://localhost:3000/api/book \
-H "Content-Type: application/json" \
-d '{"start":"2026-10-05T14:00:00.000Z","end":"2026-10-05T14:30:00.000Z","name":"Test Visitor","email":"visitor@example.com"}'
The response names the host and returns Google's event ID, and the event appears on that teammate's calendar. Sending the same request again books the next free teammate, since only the first teammate is now busy.
The route returns 409 once every teammate in BOOKING_CALENDARS is busy for that slot, and setting the variable to a single entry lets you see the 409 on the second request.
6. Wire the booking page and deploy to Webflow Cloud
Prefix browser fetches with an explicit NEXT_PUBLIC_BASE_PATH because Webflow Cloud mounts the app at a path on your site. The page is a client component that fetches slots for the coming week and posts the chosen one.
Webflow Cloud injects the mount path at build time for app routing; leave next.config unchanged.
Replace app/page.tsx with this:
'use client';
import { useEffect, useState } from 'react';
const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
type Slot = { start: string; end: string; available: string[] };
type ApiResponse = { slots?: Slot[]; host?: string; error?: string };
export default function BookingPage() {
const [slots, setSlots] = useState<Slot[]>([]);
const [selected, setSelected] = useState<Slot | null>(null);
const [status, setStatus] = useState('');
useEffect(() => {
let cancelled = false;
async function loadSlots() {
setSlots([]);
setSelected(null);
setStatus('Loading availability...');
const timeMin = new Date();
const timeMax = new Date(timeMin.getTime() + 7 * 24 * 60 * 60 * 1000);
try {
const res = await fetch(
`${BASE}/api/availability?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}`,
);
const data = (await res.json().catch(() => null)) as ApiResponse | null;
if (!res.ok) throw new Error(data?.error ?? 'Could not load availability');
if (!data || !Array.isArray(data.slots)) throw new Error('Invalid availability response');
if (!cancelled) {
setSlots(data.slots);
setStatus('');
}
} catch {
if (!cancelled) {
setSlots([]);
setSelected(null);
setStatus('Could not load availability');
}
}
}
void loadSlots();
return () => {
cancelled = true;
};
}, []);
async function book(formData: FormData) {
if (!selected) return;
setStatus('Booking...');
try {
const res = await fetch(`${BASE}/api/book`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start: selected.start,
end: selected.end,
name: formData.get('name'),
email: formData.get('email'),
}),
});
const data = (await res.json().catch(() => null)) as ApiResponse | null;
if (!res.ok) {
setStatus(data?.error ?? 'Could not complete booking');
return;
}
if (!data || typeof data.host !== 'string') {
throw new Error('Invalid booking response');
}
setStatus(`Booked with ${data.host}`);
} catch {
setStatus('Could not complete booking');
}
}
return (
<main>
<h1>Book a call</h1>
<ul>
{slots.map((slot) => (
<li key={slot.start}>
<button type="button" onClick={() => setSelected(slot)}>
{new Date(slot.start).toLocaleString()} ({slot.available.length} available)
</button>
</li>
))}
</ul>
{selected && (
<form action={book}>
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="you@example.com" required />
<button type="submit">Confirm {new Date(selected.start).toLocaleTimeString()}</button>
</form>
)}
<p>{status}</p>
</main>
);
}
The page renders in the visitor's local time through toLocaleString, while the server keeps evaluating working hours in the team's zone. As a result, visitors in other regions see correct wall-clock times without any conversion code on your side.
Prepare the project without .env.local. In Webflow Cloud, set up a project and environment for this app following Webflow's developer docs, choose a mount path such as /book, and add the variables from .env.local, with NEXT_PUBLIC_BASE_PATH set to the mount path.
Store GOOGLE_PRIVATE_KEY as a secret so it is redacted from build logs, then deploy to a private test environment.
The environment's variable list should show NEXT_PUBLIC_BASE_PATH matching the mount path.
When the build finishes, open /book on your Webflow Cloud test deployment. The slot list loads, picking a slot shows the form, and confirming it creates an event on a teammate's calendar and prints "Booked with" their name.
What causes Google Calendar booking on Webflow Cloud to fail?
Failures cluster in four places: Google rejecting the signed JWT, a calendar the service account cannot see, a client fetch that ignores the mount path, and a Next.js runtime directive the OpenNext adapter refuses to build.
The first two can be reproduced locally with npm run dev; the mount-path failure depends on the Webflow Cloud mount, and the runtime directive fails in the Webflow Cloud build.
Google returns 400 invalid_grant when the app requests a token
Cause: Google returns invalid_grant after the signed JWT reaches its token endpoint and the assertion is rejected. Check whether GOOGLE_CLIENT_EMAIL belongs to a different service account than the key. Also verify that the key has not been deleted or rotated on the account's Keys tab. Google's service account documentation defines the allowed iat and exp values.
A corrupted or double-escaped key never reaches Google: atob or crypto.subtle.importKey throws inside lib/google.ts first, and the .replace(/\\n/g, '\n') line exists to prevent that.
Fix: Open the service account in the Google Cloud console and confirm its email matches GOOGLE_CLIENT_EMAIL and the key ID in your JSON file still appears on the Keys tab. If the key was deleted, create a new one and replace both variables with those from the new file.
Keep exp at iat + 3600, as lib/google.ts sets it. Redeploy after changing either variable, and read the full error text the helper includes in its thrown message. If the error instead comes from importKey or atob, re-paste the private_key value straight from the JSON file with its -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- markers and trailing \n.
One teammate appears free in every slot, including ones you know are booked
Cause: A free/busy response can carry an errors array alongside an empty busy array for an unreadable calendar. Any code that reads only busy treats that calendar as completely open, so the person who never shared their calendar becomes the most bookable team member.
The request itself can succeed, so nothing in the route's own error handling fires. app/api/availability/route.ts filters on errors for exactly this reason, and the unreachable count in the response is your early warning.
Fix: Check the unreachable field in the availability response. If it is above zero, find which entry in BOOKING_CALENDARS is affected by calling the free/busy endpoint with one calendar ID at a time, then have that teammate share the calendar with the service account at the "Make changes to events" level.
For a secondary calendar, confirm the ID copied from its settings page matches the variable character for character. A typo in the email is another common cause, and it looks identical.
Deployed page shows no slots and /api/availability returns 404
Cause: A missing or incorrect mount-path prefix explains this 404. Webflow Cloud's bring-your-own-app documentation documents the requirement. Next.js inlines NEXT_PUBLIC_ values into the client bundle when the app builds, and Webflow Cloud makes environment variables available at build time, so changing the value requires a redeploy.
Fix: Set NEXT_PUBLIC_BASE_PATH in the Webflow Cloud environment to the mount path exactly, including the leading slash and without a trailing one, and confirm both fetch calls in app/page.tsx use the BASE prefix.
Redeploy so the new value is compiled into the bundle, then reload the page with the network tab open; the request URL should now read /book/api/availability. If you rename the mount path later, the variable has to change with it.
Webflow Cloud build fails on a Route Handler that sets the edge runtime
Cause: Webflow Cloud's docs use the word "edge" for two different concepts. Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and the docs describe that as deploying "using the Edge runtime."
The Next.js edge runtime target, set with export const runtime = 'edge', is a separate Next.js feature, and Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support it.
A reader who follows the platform sentence to its apparent conclusion adds the directive, and the build breaks. The same page currently still recommends the directive for API routes, so following the docs faithfully reproduces the failure. I treat any runtime = 'edge' export in a Webflow Cloud project as a bug to remove.
Fix: Delete every export const runtime = 'edge' line from Route Handlers, pages, and layouts, and rebuild. Webflow Cloud already runs the whole app on Workers, so the directive adds nothing. If you later add middleware, keep the middleware.ts filename.
Webflow's framework documentation for Next.js states: "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime."
What you can build next with Google Calendar and Webflow
If marketers need to change the host roster without a redeploy, move BOOKING_CALENDARS into a CMS collection that the app reads through Webflow's REST API at request time. Before building the CMS-driven roster, use Webflow's developer docs as the reference for CMS API calls and the Webflow Cloud runtime.
Frequently asked questions
Does this work with personal Gmail calendars, or does the team need Google Workspace?
Either works, and the team can mix them: share a personal Gmail calendar or a Workspace calendar with the service account and grant "Make changes to events". The catch is on the Workspace side. If the administrator limits external sharing to free/busy, users cannot grant the service account edit access, and the admin cannot make an exception for one account: the setting applies to the whole organization, organizational unit or group, and raising it lets users share editing with any outsider.
How do I stop bots from filling the team's calendars?
Keep your deployment private until you add both a verified form challenge and persistent rate limits. You should validate the challenge token in /api/book before making the free/busy request. Store counters in the same SQLite database keyed by IP or email (not the Key Value store, whose writes can take up to 60 seconds to propagate), because a module-scope counter cannot reliably cover requests handled by different Worker instances.





