Self-hosted helper for Google Calendar: when you are busy on one calendar, CalSync mirrors Busy blocks onto the others in your sync group. OAuth refresh tokens and preferences are stored per user in Supabase (Postgres). The app is multi-user: each Google sign-in gets an isolated CalSync account unless that Google identity was already linked (including via “Add another Google account”).
Latest release: v0.3.0 (2026-04-09). See Changelog.
Copy .env.example to .env.local and follow Step-by-step setup, then Run CalSync. For a public HTTPS deployment, see Recommended server configuration.
- Node.js 20 or newer (LTS recommended)
- A Google Cloud project where you can enable APIs and create OAuth credentials
- A Supabase project (free tier is fine) for the database
git clone https://github.com/srizon/CalSync.git calsync
cd calsync(Use your fork or mirror URL if different; the final argument sets the folder name.)
From the project root:
npm install-
In Google Cloud Console, select or create a project.
-
APIs & Services → Library — enable Google Calendar API.
-
APIs & Services → OAuth consent screen — configure the app (type External is fine for personal use; add your Google account as a test user if the app stays in testing).
-
APIs & Services → Credentials → Create credentials → OAuth client ID.
-
Application type: Web application.
-
Under Authorized redirect URIs, add exactly:
http://localhost:3000/api/auth/callbackFor production, add your public URL with the same path, e.g.
https://your-domain.com/api/auth/callback. -
Copy the Client ID and Client secret.
- In the Supabase dashboard, open SQL Editor and run the migration script from this repo:
supabase/migrations/20260409120000_calsync_multiuser.sql(createscalsync_users,calsync_identities,calsync_stores,calsync_watch_channelswith RLS enabled and no public policies — the app uses the service role from the server only). - Under Project Settings → API, copy the Project URL and the service_role key (keep the service role secret).
The repository includes .env.example as a safe template (no secrets). Copy it and fill in your values:
-
Copy the example env file:
cp .env.example .env.local
-
Edit
.env.localand set at minimum:Variable Description GOOGLE_CLIENT_IDOAuth client ID from step 3 GOOGLE_CLIENT_SECRETOAuth client secret from step 3 SUPABASE_URLSupabase project URL (step 4) SUPABASE_SERVICE_ROLE_KEYSupabase service role key (server only; step 4) CALSYNC_PUBLIC_URLBase URL with no trailing slash. Local: http://localhost:3000. Production: your HTTPS origin -
Production: set
CALSYNC_SESSION_SECRETto a long random string so dashboard sessions are signed securely. Optionally setCALSYNC_ALLOWED_EMAILSto a comma-separated list of Google emails allowed to sign in.
Migrating from older CalSync that used .data/store.json: leave that file in place on first start after upgrading. If the Supabase database has no users yet, the app imports the legacy file into a single CalSync user and renames the file to store.json.migrated.
See comments in .env.example for optional settings (webhook token, auto-sync interval, cron secret for renewing push subscriptions).
From the project root (with .env.local filled in):
npm run devOpen http://localhost:3000. You will be redirected to sign in; use Continue with Google, then connect calendars on the dashboard.
The dev server uses the default Next.js port 3000. To use another port:
npx next dev -p 3001Set CALSYNC_PUBLIC_URL to match (e.g. http://localhost:3001).
Build once, then start the Node server:
npm run build
npm run startBy default the app listens on port 3000. Set the PORT environment variable to listen on another port (for example PORT=8080 npm run start).
Ensure CALSYNC_PUBLIC_URL matches the URL users and Google OAuth actually use (HTTPS in production). Google Calendar push notifications require an HTTPS public URL; without HTTPS, push-related features are skipped (the cron route returns no_https_public_url when push is unavailable).
Use these guidelines when CalSync runs on a VPS, homelab host, or similar always-on environment.
Compute and Node
- Node.js 20 LTS or newer on the server.
- Sizing: CalSync is mostly I/O to Google’s APIs. A small VM (about 1 vCPU, 512 MB–1 GB RAM) is often enough; add headroom if you run other services on the same host.
Storage
- Supabase holds all user data (tokens, sync selection, push channel metadata). Use Supabase backups and point
SUPABASE_*at the same project for production. Legacy.data/store.jsonis only read once for automatic import, then renamed; you can remove.data/after a successful migration.
HTTPS and reverse proxy
- Terminate TLS at a reverse proxy (e.g. Caddy, nginx, or Traefik) or your platform’s load balancer, and proxy to the Next.js process.
- Proxy target:
http://127.0.0.1:<PORT>where<PORT>matchesPORTfornpm run start(default 3000). Binding only to localhost is fine when the proxy is on the same machine. - Forward
Host,X-Forwarded-Proto, andX-Forwarded-Forso redirects and OAuth stay aligned withCALSYNC_PUBLIC_URL.
Production environment variables
| Priority | Variable | Notes |
|---|---|---|
| Required | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
From Google Cloud OAuth client (Web application). |
| Required | SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY |
From Supabase Project Settings → API; service role is server-only (never expose in the browser). |
| Required | CALSYNC_PUBLIC_URL |
Public HTTPS origin, no trailing slash (must match what users open in the browser). |
| Required | CALSYNC_SESSION_SECRET |
Long random string; signs the dashboard session cookie. |
| Recommended | CALSYNC_ALLOWED_EMAILS |
Comma-separated Google accounts allowed to sign in (useful on the public internet). |
| Recommended | CALSYNC_WEBHOOK_TOKEN |
If set, Google Calendar push requests must send the same value in X-Goog-Channel-Token. |
| Recommended | CALSYNC_CRON_SECRET |
Protects GET /api/cron/renew-watches with Authorization: Bearer <secret>. |
| Optional | CALSYNC_AUTO_SYNC_INTERVAL_SEC |
Poll sync every N seconds while the Node process runs (e.g. 120) as a complement to push. |
Cron for push channel renewal
Google push channels expire after roughly a week. Schedule a daily HTTPS request (same host as CALSYNC_PUBLIC_URL):
curl -fsS -H "Authorization: Bearer YOUR_CALSYNC_CRON_SECRET" \
"https://your-domain.com/api/cron/renew-watches"Set CALSYNC_CRON_SECRET in .env.local (or your process manager’s environment) to match YOUR_CALSYNC_CRON_SECRET.
Process supervision
Run npm run start under a supervisor so it restarts after reboots or crashes—for example systemd, PM2, or your platform’s native service model. Example systemd unit (adjust paths and user):
[Unit]
Description=CalSync Next.js
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/calsync
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/opt/calsync/.env.local
ExecStart=/usr/bin/npm run start
Restart=on-failure
[Install]
WantedBy=multi-user.targetInstall Node via your distro or nvm so npm and node are on PATH for the service user, or set ExecStart to the full path of next / node as needed.
Google Cloud Console
- Add the production Authorized redirect URI:
https://your-domain.com/api/auth/callback(same path pattern as local, HTTPS origin only).
After you sign in, the dashboard uses two tabs:
- Upcoming events (default) — Lists events in the next 7, 30, or 90 days for calendars in your saved sync group only. Shows schedule, “free” transparency when Google marks the event that way, optional Meet/video links, and a link to open the event in Google Calendar. Use Declined events to show or hide invitations you declined (hidden by default; shown rows are muted with a Declined badge). The list uses a short loading skeleton, refreshes in the background about every minute while the tab is visible, and reloads after sync and clear-mirrors actions.
- Sync setup — Manage Google accounts and the sync group:
- Connected Google accounts — Add another account, remove one, or Disconnect all. Calendars from every linked account appear under that account’s email; busy blocks can sync across different Google logins.
- Calendars in sync group — Check at least two calendars that should both publish and receive busy mirrors. The list is grouped by account, with the primary calendar first in each group; only calendar display names are shown (not raw calendar IDs). Each calendar must be writable (owner or “Make changes to events”) on at least one connected account. Use Add calendar to Create a new calendar (optionally choose which account owns it when you have several) or Add to list with an existing calendar ID from Google Calendar → Settings → Integrate calendar. Then Save selection and Run sync now, or rely on HTTPS push notifications and optional polling (see Configure environment variables).
- After a sync, Last sync shows created/updated/deleted mirror counts, how many event rows Google returned, and (when relevant) why some events were skipped (e.g. “Show as available”, existing CalSync mirrors, cancelled events).
Refresh tokens and preferences live in your Supabase project. Back up and secure that database; the app does not persist tokens on local disk except during a one-time legacy import from .data/store.json.
API (optional): Authenticated sessions can call GET /api/events?days=30 (1–90) for JSON of the same upcoming-events window used by the dashboard. Each event object includes declinedBySelf when your RSVP on that event is Declined. You can trigger a sync with POST /api/sync (same session cookie) or from automation if you expose it appropriately. To strip only CalSync mirror blocks from one calendar (not your real events), POST /api/calendars/clear-mirrors with JSON { "calendarId": "<id>" }.
| Command | Purpose |
|---|---|
npm run dev |
Development server (Turbopack) |
npm run dev:webpack |
Development server using Webpack |
npm run build |
Production build (Turbopack) |
npm run build:webpack |
Production build using Webpack |
npm run start |
Run production server |
npm run lint |
ESLint |
- Multi-user + Supabase: Each distinct Google identity maps to a CalSync user (unless you use Add another Google account while signed in). Data lives in Supabase Postgres (
calsync_users,calsync_identities,calsync_stores,calsync_watch_channels). SetSUPABASE_URLandSUPABASE_SERVICE_ROLE_KEY. One-time import from legacy.data/store.jsonwhen the database is empty. - Sessions: Dashboard cookie now carries an internal
userId; older email-only cookies still work until they expire if the identity exists in Supabase. - Background jobs: Auto-sync, watch renewal, cron, and calendar webhooks operate per user; push webhooks resolve the user via
x-goog-channel-id.
- Documentation: Step-by-step setup starts with clone instructions; Run CalSync covers dev (default port,
npx next dev -p …) and production (npm run build/npm run start,PORT). New Recommended server configuration section: VM sizing, persisting.data/, reverse proxy headers (Host,X-Forwarded-Proto,X-Forwarded-For), production env variable table, daily cron example forGET /api/cron/renew-watches, example systemd unit, and production OAuth redirect URI. Cross-links use the updated “configure environment variables” step number. - Events API:
GET /api/eventsreturns declined invitations in the payload instead of omitting them; each row includesdeclinedBySelfso clients can filter or style them. - Agenda UI: Declined events toggle (default off) with muted row styling, Declined pill, and softer list-head and join-link treatment when shown; empty state when only declined rows exist while the toggle is off. EventsAgendaSkeleton while loading; silent refetch about every 60 seconds when the document is visible; shared
loadEventswith abort on unmount; event list refresh after sync, clear mirrors, and related actions. - Agenda layout: Custom-styled time-range
<select>; row borders and padding adjusted for the first agenda item. - Sync setup UI: Calendars in sync group are grouped under each Google account (email label). Within each account, the primary calendar is listed first, then other calendars by name. Raw calendar IDs are no longer shown in the list (display names and the primary badge only).
- Core: Next.js 16 (App Router), Google OAuth, busy-block mirroring across a chosen calendar group, local persistence in
.data/store.json, optional push notifications and cron for watch renewal (see env docs). - Dashboard: Upcoming events and Sync setup tabs; calendars merged across linked Google accounts; last-sync summary with skip reasons.
- API:
GET /api/events?days=…(1–90),POST /api/sync,POST /api/calendars/clear-mirrors(session-authenticated). - Tooling:
npm run dev/npm run build(Turbopack); optionalnpm run dev:webpackandnpm run build:webpack. - Declined invitations: Events where your RSVP is Declined are omitted from the upcoming list, are not sources for mirrors, and sync skip stats can include
declinedByYou. - Clear mirrors: Per-calendar control on Sync setup (and the clear-mirrors API) deletes CalSync mirror events over a wide past range plus the normal forward window; your own non-mirror events are untouched.
- Sync cleanup: Duplicate CalSync mirrors on the same target (same mirror key) are deleted during sync.
- UI: Calendar create/add flows were removed from the home page in favor of managing the sync group and calendars in Google Calendar / settings.
- Agenda: The upcoming-events list hides items that have already ended (timed and all-day); the view refreshes when the next event in range ends. List-head badges use urgency colors (calm → urgent) from time until start or, for live timed events, time until end. The footer shows how many events are still on the agenda versus how many in the window already ended, with a clear empty state when everything in range is past.
- Login: OAuth error text from the query string is read with
useSearchParamsinside aSuspenseboundary so static generation and ESLint stay clean.
Next.js 16 (App Router), React 19, Tailwind CSS 4, Supabase (Postgres), and the Google Calendar API via @googleapis/calendar and google-auth-library.