sanity-google-calendar
v0.1.0
Published
Sync a Google Calendar into Sanity event documents, and read recurring events on the front end. Framework-agnostic.
Maintainers
Readme
📅 sanity-google-calendar 📆
Keep your events in Google Calendar, where people already manage them, and publish them through Sanity!
This is based on an implementation I wrote for Marz Brewery. The staff of Marz updates a shared Google Calendar with their weekly and one-time events, and they magically show up on the website. The calendar fits naturally into their workflow and requires next to no training, they’ve been using it for over two years without a hitch.
This package does three things:
- Schema: provides an
eventdocument type for your Sanity Studio. - Sync: a one-way sync from a Google Calendar (via its iCal feed) into Sanity, including poster images attached to calendar events in Google Drive.
- Front end: a library of helpers for recurring events. "Does this event happen on Tuesday the 14th?", "every other Thursday @ 7:00 PM", "which events are still upcoming?"
It's framework-agnostic. The sync is a standard Fetch API handler, (Request) => Response, so it drops into Next.js App Router, React Router / Remix, Hono, etc. The sync uses only Fetch and Web Crypto, so it runs on Node 20+ and on edge runtimes like Cloudflare Workers (no nodejs_compat flag needed). The front-end helpers have no dependencies at all.
coming soon: I am going to build an example repo that goes into a little more detail on how to expose the calendar on your front end.
Installation
npm install sanity-google-calendar
// or yarn install sanity-google-calendar
// or pnpm add sanity-google-calendarThere are three entry points. Each is safe to import only where it's needed:
| Import | Where | What |
| ------------------------------- | ----------------- | ---------------------------------------------------------------------- |
| sanity-google-calendar | anywhere | eventOccursOn, readableDate, filterCurrentAndFutureEvents, types |
| sanity-google-calendar/schema | Studio / anywhere | createEventSchema, eventSchema |
| sanity-google-calendar/sync | server only | createSyncHandler, syncCalendar, createGoogleDriveFetcher |
None of them import sanity at runtime, so importing the schema from a server route won't drag the Studio bundle along.
1. Schema
This isn't a Studio plugin. It's a schema type you add to your own schema like any other.
// sanity.config.ts (or wherever your schema types live)
import { eventSchema } from 'sanity-google-calendar/schema'
export default defineConfig({
// …
schema: { types: [eventSchema /* , … */] },
})Or customize it:
import { defineField } from 'sanity'
import { createEventSchema } from 'sanity-google-calendar/schema'
export const event = createEventSchema({
name: 'event', // document type; must match the sync's documentType
descriptionRichType: 'richText', // reuse your own Portable Text type (default: inline array of blocks)
posterValidation: (rule) => rule.required(),
// your own fields, appended after the synced ones
fields: [
defineField({
name: 'venue',
type: 'reference',
to: [{ type: 'venue' }],
group: 'data',
}),
],
})Synced fields:
summarystatussluglocation(the raw address string from the calendar)description(plain text)descriptionRich(Portable Text)startenddtstampsequencerrule// this is a "recurrence rule", e.g. "event repeats every other Tuesday"postergoogleDrivePosterId.
⚠️ The sync uses
createOrReplace, so Studio edits to synced fields are overwritten on the next sync. Your calendar is the source of truth! Edit events in Google Calendar. Your own fields get replaced too, unless yourtransformsets them (see below).
2. Sync
Get the feed URL
In Google Calendar, open the calendar's Settings and sharing → Integrate calendar → Secret address in iCal format. Keep it secret: anyone with it can read the calendar.
Add a route
Next.js App Router:
// app/api/calendar-sync/route.ts
import { createClient } from '@sanity/client' // or next-sanity's createClient
import { bearerTokenAuth, createSyncHandler } from 'sanity-google-calendar/sync'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
apiVersion: '2025-01-01',
token: process.env.SANITY_WRITE_TOKEN,
useCdn: false,
})
export const GET = createSyncHandler({
calendarUrl: process.env.CALENDAR_ICAL_URL!,
client,
authorize: bearerTokenAuth(process.env.CRON_SECRET),
})React Router (v7) resource route:
// app/routes/api.calendar-sync.ts
import type { Route } from './+types/api.calendar-sync'
import { createClient } from '@sanity/client' // or next-sanity's createClient
import { createSyncHandler } from 'sanity-google-calendar/sync'
const client = createClient(...) // see above
const sync = createSyncHandler({ calendarUrl, client, authorize })
export const loader = ({ request }: Route.LoaderArgs) => sync(request)Anything else: syncCalendar(options) is the same thing without the HTTP wrapper. It returns { events, committed, transactionId }. Call it from a cron job, a CLI script or a queue worker.
Triggering it
Nothing runs automatically. Call the route on a schedule, e.g. with Vercel Cron:
// vercel.json
{ "crons": [{ "path": "/api/calendar-sync", "schedule": "*/30 * * * *" }] }Vercel Cron sends Authorization: Bearer $CRON_SECRET, which is what bearerTokenAuth checks.
Add ?dryRun=1 to preview what would be written without touching Sanity.
Posters from Google Drive
When you attach an image to a Google Calendar event, it's stored in Drive. The feed only includes a link, so downloading it requires a Google service account:
- Create a service account in Google Cloud and enable the Google Drive API.
- Either share the attachments' Drive folder with the service account's email, or grant it domain-wide delegation for the
https://www.googleapis.com/auth/drive.readonlyscope and impersonate the calendar owner withsubject. - Download a JSON key and put it in an env var. Base64-encoding the file keeps it on one line:
base64 -i key.json.
import {
createGoogleDriveFetcher,
createSyncHandler,
parseServiceAccountKey,
} from 'sanity-google-calendar/sync'
const fetchAttachment = createGoogleDriveFetcher({
...parseServiceAccountKey(process.env.GOOGLE_SERVICE_ACCOUNT_KEY!), // raw or base64 JSON
subject: '[email protected]', // only with domain-wide delegation
})
export const GET = createSyncHandler({ calendarUrl, client, fetchAttachment })This uses fetch and WebCrypto directly, not googleapis, so it adds nothing to your bundle. Posters already uploaded for the same Drive file are reused instead of being downloaded again on every sync. You can also pass your own fetchAttachment(fileId, { filename, contentType }) => Promise<Blob>.
Event descriptions
Google Calendar descriptions are plain text or a small subset of HTML. The sync converts them to Portable Text for descriptionRich with a built-in converter, so no DOM library is needed. It handles:
- Paragraphs, headings and blockquotes, as blocks with the matching style
- Line breaks, kept as
\ninside a block - Bulleted and numbered lists, including nested ones
- Bold, italic, underline, strikethrough and code, as decorators
- Links, as
linkannotations
Other tags are unwrapped and only their text is kept. Whitespace collapses the way a browser renders it, but non-breaking spaces are kept. Empty paragraphs are dropped. The output otherwise matches @portabletext/block-tools. description is the same text flattened to a string, with blocks joined by spaces.
Project-specific fields: beforeSync and transform
The package doesn't know anything about your venues, categories, or other fields. Add them yourself:
createSyncHandler({
calendarUrl,
client,
// runs once per sync; the result is passed to transform
beforeSync: (client) =>
client.fetch<Array<{ _id: string; address: string }>>(
`*[_type == "venue"]{ _id, address }`,
{},
),
// runs per event; return the document to write, or null to skip it
transform: (doc, vevent, venues) => {
if (doc.summary.startsWith('[private]')) return null
const venue = venues.find((v) => doc.location?.includes(v.address))
return {
...doc,
venue: venue && { _type: 'reference', _ref: venue._id },
}
},
})Other options
| Option | Default | Notes |
| -------------- | ------- | ---------------------------------- |
| documentType | event | Must match your schema's name. |
| dryRun | false | Convert everything, write nothing. |
| concurrency | 4 | Parallel poster downloads/uploads. |
3. Front end
Recurrence rules are stored on each event as a JSON string (rrule). These helpers read it. They only need start and rrule on your query results.
import {
eventOccursOn,
filterCurrentAndFutureEvents,
parseRecurrenceRule,
readableDate,
} from 'sanity-google-calendar'
// "Friday, September 4 — 7:00 PM" / "every other Tuesday @ 7:00 PM" / "every first Friday @ 7:00 PM"
readableDate(event, { timeZone: 'America/Chicago' })
// does this event happen on this calendar day? (one-off, weekly, every-N-weeks,
// nth weekday of the month, every-N-months; respects dtstart and until)
eventOccursOn(event.start, parseRecurrenceRule(event.rrule), day)
// drop past one-off events and recurring events that have ended
filterCurrentAndFutureEvents(events)Pass timeZone to readableDate, or times render in the server's or browser's zone, which is usually not what you want on a server-rendered site.
Supported recurrence
Weekly on one or more days (every Tuesday, Thursday), every N weeks, the nth weekday of the month (every second Tuesday, every last Sunday), and the nth weekday every N months. Rules with an until date are respected. Yearly rules, bymonthday and count aren't interpreted yet. Such events still show on their start date.
Development
pnpm test # vitest
pnpm build # @sanity/pkg-utils → distLicense
MIT © Eric Jacobsen
