npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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:

  1. Schema: provides an event document type for your Sanity Studio.
  2. 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.
  3. 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-calendar

There 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:

  • summary
  • status
  • slug
  • location (the raw address string from the calendar)
  • description (plain text)
  • descriptionRich (Portable Text)
  • start
  • end
  • dtstamp
  • sequence
  • rrule // this is a "recurrence rule", e.g. "event repeats every other Tuesday"
  • poster
  • googleDrivePosterId.

⚠️ 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 your transform sets 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:

  1. Create a service account in Google Cloud and enable the Google Drive API.
  2. 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.readonly scope and impersonate the calendar owner with subject.
  3. 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 \n inside a block
  • Bulleted and numbered lists, including nested ones
  • Bold, italic, underline, strikethrough and code, as decorators
  • Links, as link annotations

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 → dist

License

MIT © Eric Jacobsen