Remote Sync Overview

DayFlow provides headless sync packages for connecting your calendar to remote servers:

  • @dayflow/caldav — CalDAV sync engine. Works with iCloud Calendar, Nextcloud, Radicale, Fastmail, and any RFC 4791-compliant CalDAV server.
  • @dayflow/google-sync — Google Calendar REST API sync engine.
  • @dayflow/outlook-sync — Microsoft Outlook Calendar sync engine via the Microsoft Graph API.
  • @dayflow/sync-core — provider-neutral reconciliation helpers for custom providers and backend-led sync.

Most applications should start with a provider package (@dayflow/caldav, @dayflow/google-sync, or @dayflow/outlook-sync). Reach for @dayflow/sync-core only when you are building your own provider package, reconciling remote data in a backend job, or need provider-neutral audit/history changes.

Design Principles

No credentials in the browser

The provider packages do not accept passwords, tokens, or OAuth secrets directly. All authentication lives in your backend. The browser only talks to your own proxy server.

Browser (DayFlow)  →  Your backend proxy  →  CalDAV / Google / Outlook API

This means DayFlow never sees your users' credentials, and you remain in full control of your authentication strategy.

Adapter-first transport

Each provider package takes a user-supplied fetch function for transport. You inject an authenticated fetch — the sync engine executes requests through it without knowing what credentials are attached.

const adapter = createCalDAVAdapter({
  calendarHomeUrl: 'https://caldav.example.com/calendars/alice/',
  fetch: (url, init) =>
    fetch('/api/caldav-proxy', {
      method: 'POST',
      body: JSON.stringify({ url, init }),
    }),
});

Observable, not prescriptive

Provider packages emit structured callbacks rather than owning persistence:

  • onSyncComplete(delta) — called after each sync with counts of what changed, so your store can react without re-diffing.
  • onWriteComplete(operation, event) — called after each successful write-back, so you can update a local DB with server-assigned IDs.
  • getInitialSnapshot() — called once on startup to seed DayFlow from your local cache before any API requests, so the calendar renders instantly.

You own the storage. The packages tell you what happened.

Headless

Provider packages do not ship login forms, OAuth popups, credential storage, or a hosted sync service. They expose sync infrastructure. Your application owns the authentication experience.

Read-only and write-back

Provider packages support:

  • Read-only mode (writable: false) — syncs events from the server into DayFlow without writing back.
  • Write-back mode (writable: true, default) — writes local creates, updates, and deletes back to the server.

Complete CalDAV Example

This example shows the full shape of a production-style CalDAV integration:

  1. A backend proxy stores credentials and forwards CalDAV methods.
  2. The frontend creates a proxied fetch function.
  3. The CalDAV adapter discovers or receives the user's calendar home URL.
  4. DayFlow is seeded from a local cache, then synchronized in the background.
  5. Local edits write back only when the remote calendar is writable.

1. Install

npm install @dayflow/caldav
pnpm add @dayflow/caldav
yarn add @dayflow/caldav
bun add @dayflow/caldav

2. Configure the backend

Use provider-specific app passwords when the provider supports them. Do not send these values to the browser.

# iCloud
CALDAV_BASE_URL=https://caldav.icloud.com/
CALDAV_USERNAME=alice@icloud.com
CALDAV_PASSWORD=xxxx-xxxx-xxxx-xxxx

# Nextcloud
# CALDAV_BASE_URL=https://nextcloud.example.com/remote.php/dav/
# CALDAV_USERNAME=alice
# CALDAV_PASSWORD=nextcloud-app-password

# Radicale
# CALDAV_BASE_URL=https://radicale.example.com/

# Fastmail
# CALDAV_BASE_URL=https://caldav.fastmail.com/dav/

Provider notes:

ProviderRecommended setup
iCloudUse an Apple app-specific password and createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, ...) for dynamic home discovery.
NextcloudUse nextcloudConfig(host, username) when you already know the username; use an app password from Personal Settings.
RadicaleSome servers omit permissions. DayFlow treats ambiguous calendars as read-only unless the adapter result says otherwise.
FastmailUse fastmailConfig('https://caldav.fastmail.com/dav', email) with a backend proxy.

3. Add a proxy route

This Next.js App Router route accepts JSON-wrapped CalDAV requests from the browser, injects Basic Auth, restricts the upstream URL to your configured CalDAV base URL, and returns the upstream XML/ICS response.

app/api/caldav/route.ts
export const runtime = 'nodejs';

const ALLOWED_METHODS = new Set(['PROPFIND', 'REPORT', 'PUT', 'DELETE']);

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

function corsHeaders() {
  return {
    'Access-Control-Allow-Origin':
      process.env.APP_ORIGIN ?? 'http://localhost:3000',
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Access-Control-Expose-Headers': 'ETag, DAV',
  };
}

export function OPTIONS() {
  return new Response(null, { status: 204, headers: corsHeaders() });
}

export async function POST(request: Request) {
  const baseUrl = requireEnv('CALDAV_BASE_URL');
  const username = requireEnv('CALDAV_USERNAME');
  const password = requireEnv('CALDAV_PASSWORD');
  const { url, init = {} } = (await request.json()) as {
    url: string;
    init?: RequestInit;
  };

  const target = new URL(url);
  const base = new URL(baseUrl);
  const method = init.method ?? 'PROPFIND';

  if (!target.href.startsWith(base.href)) {
    return new Response('Forbidden upstream URL', {
      status: 403,
      headers: corsHeaders(),
    });
  }
  if (!ALLOWED_METHODS.has(method)) {
    return new Response('Method not allowed', {
      status: 405,
      headers: corsHeaders(),
    });
  }

  const headers = new Headers(init.headers);
  headers.set(
    'Authorization',
    `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
  );

  const upstream = await fetch(target.href, {
    method,
    headers,
    body: typeof init.body === 'string' ? init.body : undefined,
  });

  const responseHeaders = new Headers(corsHeaders());
  for (const name of ['Content-Type', 'ETag', 'DAV', 'Last-Modified']) {
    const value = upstream.headers.get(name);
    if (value) responseHeaders.set(name, value);
  }

  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: responseHeaders,
  });
}

For multi-user products, load credentials from the signed-in user's encrypted account record instead of process-level environment variables. The important rule is the same: DayFlow sends CalDAV protocol requests to your backend, and your backend adds credentials.

4. Create durable sync storage

createCalDAVSync can run with in-memory storage, but production apps should persist sync tokens, ETags, and event remote references. Without durable storage, every reload loses incremental sync state and conditional write metadata.

caldav-storage.ts
import type { CalDAVStorage } from '@dayflow/caldav';

const key = (accountId: string, name: string, id: string) =>
  `dayflow:caldav:${accountId}:${name}:${id}`;

export function createLocalCalDAVStorage(accountId: string): CalDAVStorage {
  return {
    getSyncToken: async calendarId =>
      localStorage.getItem(key(accountId, 'sync-token', calendarId)),
    setSyncToken: async (calendarId, token) => {
      const storageKey = key(accountId, 'sync-token', calendarId);
      if (token) localStorage.setItem(storageKey, token);
      else localStorage.removeItem(storageKey);
    },
    getCtag: async calendarId =>
      localStorage.getItem(key(accountId, 'ctag', calendarId)),
    setCtag: async (calendarId, ctag) => {
      localStorage.setItem(key(accountId, 'ctag', calendarId), ctag);
    },
    getEtag: async href => localStorage.getItem(key(accountId, 'etag', href)),
    setEtag: async (href, etag) => {
      localStorage.setItem(key(accountId, 'etag', href), etag);
    },
    deleteEtag: async href => {
      localStorage.removeItem(key(accountId, 'etag', href));
    },
    getEventState: async eventId =>
      JSON.parse(
        localStorage.getItem(key(accountId, 'event-state', eventId)) ?? 'null'
      ),
    setEventState: async (eventId, state) => {
      localStorage.setItem(
        key(accountId, 'event-state', eventId),
        JSON.stringify(state)
      );
    },
    deleteEventState: async eventId => {
      localStorage.removeItem(key(accountId, 'event-state', eventId));
    },
    clearCalendar: async calendarId => {
      const prefix = `dayflow:caldav:${accountId}:`;
      for (const storageKey of Object.keys(localStorage)) {
        if (!storageKey.startsWith(prefix)) continue;

        const value = localStorage.getItem(storageKey);
        const isCalendarKey =
          storageKey === key(accountId, 'sync-token', calendarId) ||
          storageKey === key(accountId, 'ctag', calendarId) ||
          storageKey.startsWith(key(accountId, 'etag', calendarId));
        let isEventStateForCalendar = false;
        if (storageKey.startsWith(`${prefix}event-state:`) && value) {
          try {
            isEventStateForCalendar =
              JSON.parse(value).calendarId === calendarId;
          } catch {
            isEventStateForCalendar = false;
          }
        }

        if (isCalendarKey || isEventStateForCalendar) {
          localStorage.removeItem(storageKey);
        }
      }
    },
  };
}

Use IndexedDB or your own backend database if you need cross-device sync cache, encryption, large event histories, or account-level cleanup.

5. Attach CalDAV to DayFlow

This React example uses iCloud-style discovery. Swap the adapter creation block for nextcloudConfig, radicaleConfig, or fastmailConfig when you already know the provider's calendar home URL.

CalDAVCalendar.tsx
import { useEffect, useRef } from 'react';
import {
  DayFlowCalendar,
  createMonthView,
  useCalendarApp,
} from '@dayflow/react';
import {
  ICLOUD_CALDAV_SERVER,
  attachCalDAVToDayFlow,
  createCalDAVAdapter,
  createCalDAVAdapterFromServer,
  createCalDAVSync,
  createNamespacedCalDAVEventId,
  nextcloudConfig,
  type CalDAVDayFlowController,
  type CalDAVSyncStatus,
} from '@dayflow/caldav';
import { createLocalCalDAVStorage } from './caldav-storage';

type Provider = 'icloud' | 'nextcloud';

type Props = {
  accountId: string;
  provider: Provider;
  nextcloudHost?: string;
  nextcloudUsername?: string;
  onStatusChange?: (status: CalDAVSyncStatus) => void;
};

export function CalDAVCalendar({
  accountId,
  provider,
  nextcloudHost,
  nextcloudUsername,
  onStatusChange,
}: Props) {
  const calendar = useCalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });
  const controllerRef = useRef<CalDAVDayFlowController | null>(null);

  useEffect(() => {
    let disposed = false;

    const proxiedFetch = (url: string, init?: RequestInit) =>
      fetch('/api/caldav', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url, init }),
      });

    async function startSync() {
      const adapter =
        provider === 'icloud'
          ? await createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, {
              fetch: proxiedFetch,
            })
          : createCalDAVAdapter({
              ...nextcloudConfig(nextcloudHost!, nextcloudUsername!),
              fetch: proxiedFetch,
            });

      if (disposed) return;

      const sync = createCalDAVSync({
        adapter,
        storage: createLocalCalDAVStorage(accountId),
        rangeChunkDays: 31,
      });

      const controller = attachCalDAVToDayFlow(calendar.app, sync, {
        writable: true,
        refreshOnVisibleRangeChange: true,
        maxConcurrentCalendars: 4,
        createEventId: createNamespacedCalDAVEventId,
        eventMode: {
          recurring: 'read-only',
        },
        getInitialSnapshot: async () => {
          const cached = await loadCachedCalDAVSnapshot(accountId);
          return cached ?? { calendars: [], events: [] };
        },
        onSyncComplete: async delta => {
          await persistSyncedDayFlowState(accountId, calendar.app);
          console.info('CalDAV sync complete', delta);
        },
        onWriteComplete: async () => {
          await persistSyncedDayFlowState(accountId, calendar.app);
        },
        onError: (error, context) => {
          console.error('CalDAV sync failed', context, error);
          onStatusChange?.(controller.getStatus());
        },
      });

      controllerRef.current = controller;
      await controller.start();
      onStatusChange?.(controller.getStatus());
    }

    startSync().catch(error => {
      console.error('Failed to start CalDAV sync', error);
    });

    return () => {
      disposed = true;
      controllerRef.current?.stop();
      controllerRef.current = null;
    };
  }, [
    accountId,
    calendar.app,
    nextcloudHost,
    nextcloudUsername,
    onStatusChange,
    provider,
  ]);

  return <DayFlowCalendar calendar={calendar} />;
}

The example references two app-owned persistence helpers:

import type { ICalendarApp } from '@dayflow/core';

async function loadCachedCalDAVSnapshot(accountId: string) {
  // Return { calendars, events } from IndexedDB, Supabase, your backend, etc.
  // Return null when there is no cache yet.
  return null;
}

async function persistSyncedDayFlowState(accountId: string, app: ICalendarApp) {
  // Persist app.getCalendars() and app.getAllEvents() into your local store.
  // Keep this separate from CalDAVStorage, which stores sync metadata only.
}

6. Verify write-back behavior

Before enabling write-back for real users, test these flows against the target provider:

FlowExpected result
Initial loadRemote calendars appear, then events load for the visible range.
Navigate to next monthrefreshOnVisibleRangeChange fetches that range.
Create a local eventA .ics resource is created remotely and the local event receives CalDAV href/etag meta.
Edit a local eventThe update uses the latest ETag and refreshes local CalDAV meta after the server responds.
Delete a local eventThe remote resource is deleted when the calendar is writable.
Recurring event editThe DayFlow binding treats recurring events as read-only.
Read-only calendar editNo write is attempted when the remote calendar lacks create/update/delete permissions.

Production checklist

AreaWhat to check
CredentialsStore provider credentials server-side only; use app passwords where available; rotate or revoke on disconnect.
Proxy allowlistRestrict upstream URLs to the user's configured provider base URL; allow only PROPFIND, REPORT, PUT, and DELETE.
Durable sync storagePersist CalDAVStorage per account so sync tokens, ETags, and remote hrefs survive reloads.
Local cacheUse getInitialSnapshot for fast first paint, but keep credentials and sync metadata out of the event cache.
Event identityKeep createNamespacedCalDAVEventId unless you have a migration plan; it prevents collisions with local events and other providers.
Partial snapshotsKeep the default partial snapshot mode for range-limited provider responses; use snapshotMode: 'authoritative' only for full snapshots.
Provider quirksiCloud needs discovery; Nextcloud should use app passwords; Radicale may appear read-only; Fastmail uses the /dav/principals/user/... path.

What Is Not Supported

LimitationDetail
Recurring event editingRecurring events are read-only. Editing, dragging, or deleting recurring instances is blocked.
Built-in OAuthOAuth flows, token refresh, and credential storage are your responsibility.
Offline supportProvider packages require a live network connection.
Conflict UIETag conflicts (412) are retried automatically; manual conflict resolution UI is not included.
CalDAV CORSCalDAV servers do not support browser CORS — a backend proxy is required.
Google CORSThe Google Calendar API supports CORS, but sending tokens from the browser is a security risk — use a proxy.

On this page