Moltenship Time
Guides

Show an ICS feed

Parse a Google, Outlook or iCloud feed on the server and show its events in the calendar.

Live, in your time zone. Parsed in your browser from the ICS feed below.
The feed’s ICS source

Writing the feed…

These events come from an ICS feed, the iCalendar text that Google Calendar, Outlook and iCloud publish. The feed was written for this week, parsed in your browser with @moltenship/ics, and shown in your time zone. Each title says where its time came from: a New York time zone, an Outlook zone name, UTC, a date, or a floating time with no zone at all. Open the source to compare.

This guide does the same in a Next.js app: the server fetches and parses the feed, and the browser shows it. The Next.js guide covers the rules for server rendering that apply here too.

Install

The parser is a separate package. It reads iCalendar text into Temporal values and doesn't depend on the calendar, so it works on its own too.

Not on npm yet

Like the calendar, @moltenship/ics hasn't been published. The commands below are what you'll run after the first release.

pnpm add @moltenship/ics

It needs Temporal, as the calendar does: Node.js 26 has it, and the browser may need the polyfill.

Fetch and parse on the server

A Server Component fetches the feed, parses it with parseIcs, and maps the events to plain JSON for the client:

page.tsx
// app/team/page.tsx (a Server Component)
import { parseIcs } from "@moltenship/ics";
import { cacheLife } from "next/cache";
import { connection } from "next/server";
import { Suspense } from "react";

import { FeedCalendar } from "./feed-calendar";
import { toFeedEvents, type FeedEventJson } from "./feed-events";

const feedUrl = "https://calendar.example.com/team/basic.ics";

async function getFeedEvents(timeZone: string): Promise<readonly FeedEventJson[]> {
  "use cache";
  cacheLife("hours"); // fetch the feed again at most once an hour

  const response = await fetch(feedUrl);
  if (!response.ok) throw new Error(`The feed answered ${response.status}`);

  const { calendars, issues } = parseIcs(await response.text());
  for (const issue of issues) {
    console.warn(`${feedUrl}, line ${issue.line ?? "?"}: ${issue.message}`);
  }
  return calendars.flatMap((calendar) => toFeedEvents(calendar, timeZone));
}

export default function TeamPage() {
  return (
    <Suspense fallback={<p>Loading the team calendar…</p>}>
      <TeamFeed />
    </Suspense>
  );
}

async function TeamFeed() {
  await connection(); // render on each request, for a fresh `now`

  // The viewer's time zone, for example from their profile or a cookie.
  const timeZone = "Europe/Berlin";
  const events = await getFeedEvents(timeZone);

  return (
    <FeedCalendar events={events} now={Temporal.Now.instant().toString()} timeZone={timeZone} />
  );
}
  • Caching. Feeds change slowly, and providers limit how often you fetch them. "use cache" with cacheLife("hours") keeps the result for an hour. The time zone is an argument, so each time zone gets its own entry. Without Cache Components, pass { next: { revalidate: 3600 } } to fetch instead.
  • Issues. parseIcs doesn't throw on bad data. It keeps what it can and lists each problem in issues, with its line. The page logs them; see Issues for more.
  • The current time. connection() renders the calendar on each request, so now is today rather than the day you built the app. That makes it request-time data, so it sits in a <Suspense> boundary: the rest of the page still prerenders, and the build doesn't stop on it.

Temporal values can't cross from a Server Component to a Client Component, so toFeedEvents returns ISO 8601 strings. The client turns them back into Temporal values:

feed-calendar.tsx
// app/team/feed-calendar.tsx
"use client";

import { createCalendar, defineEventAdapter } from "@moltenship/calendar";
import { useMemo } from "react";

import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import type { CalendarEventColor } from "@/components/calendar/event-colors";
import { createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";

import type { FeedEventJson } from "./feed-events";

export type FeedEvent = {
  readonly id: string;
  readonly title: string;
  readonly color: CalendarEventColor;
} & (
  | { readonly kind: "timed"; readonly start: Temporal.Instant; readonly end: Temporal.Instant }
  | {
      readonly kind: "all-day";
      readonly start: Temporal.PlainDate;
      readonly end: Temporal.PlainDate;
    }
);

/** Turns the strings from the server back into Temporal values. */
export function fromFeedJson({ kind, start, end, ...event }: FeedEventJson): FeedEvent {
  return kind === "timed"
    ? { ...event, kind, start: Temporal.Instant.from(start), end: Temporal.Instant.from(end) }
    : { ...event, kind, start: Temporal.PlainDate.from(start), end: Temporal.PlainDate.from(end) };
}

const Calendar = createCalendar<FeedEvent>()({
  eventAdapter: defineEventAdapter<FeedEvent>()({
    getId: (event) => event.id,
    getTitle: (event) => event.title,
    // A FeedEvent is already an interval: its kind, start and end.
    getInterval: (event) => event,
    getResourceIds: () => [],
  }),
  views: [createWeekView<FeedEvent>({ getEventColor: (event) => event.color })],
});

const weekRules = { firstDay: 1, minimalDays: 4, weekend: [6, 7] } as const;

interface FeedCalendarProps {
  readonly events: readonly FeedEventJson[];
  readonly now: string;
  readonly timeZone: string;
}

export function FeedCalendar({ events, now, timeZone }: FeedCalendarProps) {
  const feedEvents = useMemo(() => events.map(fromFeedJson), [events]);
  const nowInstant = Temporal.Instant.from(now);

  return (
    <Calendar.Root
      events={feedEvents}
      now={nowInstant}
      defaultAnchorDate={nowInstant.toZonedDateTimeISO(timeZone).toPlainDate()}
      defaultTimeZone={timeZone}
      locale="en-US"
      weekRules={weekRules}
    >
      <Calendar.Frame className="flex h-[640px] flex-col rounded-xl border">
        <CalendarToolbar calendar={Calendar} />
        <Calendar.Viewport className="min-h-0 flex-1">
          <Calendar.ActiveView />
        </Calendar.Viewport>
      </Calendar.Frame>
    </Calendar.Root>
  );
}

Map ICS events to calendar events

ICS writes times in five ways, and the parser keeps them apart as start.kind. toFeedEvent turns each into an event the calendar can show:

feed-events.ts
// app/team/feed-events.ts
import { getEventEnd, type IcsCalendar, type IcsDateValue, type IcsEvent } from "@moltenship/ics";

import type { CalendarEventColor } from "@/components/calendar/event-colors";

/** A feed event as plain JSON, ready to pass from a Server Component to a Client Component. */
export interface FeedEventJson {
  readonly id: string;
  readonly title: string;
  readonly color: CalendarEventColor;
  readonly kind: "timed" | "all-day";
  /** An instant, such as "2026-10-12T07:00:00Z", or a date for an all-day event. */
  readonly start: string;
  readonly end: string;
}

// The calendar needs an end after the start; ICS allows events that take no time.
const minimumLength = { minutes: 30 };

/** The zone for floating and unresolved times: the feed's X-WR-TIMEZONE, else the viewer's. */
export function feedTimeZone(calendar: IcsCalendar, viewerTimeZone: string): string {
  return calendar.timeZone ?? viewerTimeZone;
}

function toInstant(value: IcsDateValue, timeZone: string): Temporal.Instant {
  switch (value.kind) {
    case "utc":
      return value.instant;
    case "zoned":
      return value.zoned.toInstant();
    case "date":
      return value.date.toZonedDateTime(timeZone).toInstant();
    case "floating":
    case "unresolved":
      return value.wallTime.toZonedDateTime(timeZone).toInstant();
  }
}

/** One event for the calendar, or `undefined` for a cancelled one. */
export function toFeedEvent(event: IcsEvent, timeZone: string): FeedEventJson | undefined {
  if (event.status === "CANCELLED") return undefined;

  const end = getEventEnd(event); // DTEND, else DTSTART + DURATION, in the start's kind
  let times: Pick<FeedEventJson, "kind" | "start" | "end">;
  if (event.start.kind === "date") {
    const start = event.start.date;
    const endDate = end.kind === "date" ? end.date : start;
    const isAfter = Temporal.PlainDate.compare(endDate, start) > 0;
    times = {
      kind: "all-day",
      start: start.toString(),
      end: (isAfter ? endDate : start.add({ days: 1 })).toString(),
    };
  } else {
    const start = toInstant(event.start, timeZone);
    // A DTEND in UTC or a time zone keeps its instant, even after a floating start.
    const absoluteEnd = event.end?.kind === "utc" || event.end?.kind === "zoned";
    const endInstant = toInstant(absoluteEnd ? event.end! : end, timeZone);
    const isAfter = Temporal.Instant.compare(endInstant, start) > 0;
    times = {
      kind: "timed",
      start: start.toString(),
      end: (isAfter ? endInstant : start.add(minimumLength)).toString(),
    };
  }

  return {
    // An override (RECURRENCE-ID) shares its series' UID, so its start keeps the ID unique.
    id: event.recurrenceId === undefined ? event.uid : `${event.uid}/${times.start}`,
    title: event.summary ?? "Busy",
    // Free time (TRANSP:TRANSPARENT) and tentative events are gray, the rest blue.
    color: event.transparency === "TRANSPARENT" || event.status === "TENTATIVE" ? "gray" : "blue",
    ...times,
  };
}

/** Every event of a parsed calendar, ready for the client. */
export function toFeedEvents(
  calendar: IcsCalendar,
  viewerTimeZone: string,
): readonly FeedEventJson[] {
  const timeZone = feedTimeZone(calendar, viewerTimeZone);
  return calendar.events.flatMap((event) => toFeedEvent(event, timeZone) ?? []);
}
kindWritten asShown as
utcDTSTART:20261012T090000ZA timed event at that instant
zonedDTSTART;TZID=Europe/Berlin:…A timed event at zoned.toInstant()
dateDTSTART;VALUE=DATE:20261012An all-day event
floatingDTSTART:20261012T090000A timed event at that wall time, in a zone you choose
unresolvedDTSTART;TZID=Our Office:…The same as floating
  • Floating times have a clock time and no zone. A feed's X-WR-TIMEZONE, which Google adds, says which zone the publisher meant, and the parser gives it to you as calendar.timeZone. feedTimeZone uses it, and the viewer's time zone when the feed has none. The preview's feed has none, so its focus time shows at the same clock time wherever you are.
  • The end. getEventEnd returns DTEND, else DTSTART plus DURATION, else the RFC's default: the next day for a date, the start itself for a time. It's always the same kind as the start.
  • No length. ICS lets an event end when it starts, but a calendar event's end must come after its start, so those events show as half an hour long, or one day for a date.
  • Cancelled events. Feeds keep a cancelled event, with STATUS:CANCELLED, so that subscribers can remove it. toFeedEvent skips it.
  • Colors. Events marked free (TRANSP:TRANSPARENT) or tentative are gray, the rest blue. Change the rule to suit your app, or color each feed differently.

Recurring events

The parser reads recurrence but doesn't expand it yet. A series is one event with its rule in recurrenceRule, the occurrences it removes in exceptionDates, and extra ones in recurrenceDates. A change to one occurrence is a separate event with the series' UID and a recurrenceId that names the occurrence it replaces. Expanding rules into occurrences comes with the recurrence package (#48).

So the mapping above shows:

  • A recurring event once, at its DTSTART, which is the first occurrence. The rest of the series doesn't appear, and the first occurrence shows even if exceptionDates removes it.
  • A changed occurrence as its own event, at its new time. If it changes the first occurrence, both appear.

Until then, if your feeds repeat, expand each series on the server before you map it, with an RRULE library or your own code over recurrenceRule. Leave out the dates in exceptionDates, add those in recurrenceDates, and use a changed occurrence in place of the one its recurrenceId names. If that's more than you need, check event.recurrenceRule !== undefined and tell people that the series continues.

Time zones

A TZID is a name the file chooses, and the parser maps it to a time zone Temporal knows: an IANA name such as America/New_York, a Windows or Outlook name such as W. Europe Standard Time, the file's own VTIMEZONE, and a few more. A TZID that matches none of them isn't guessed. The time stays unresolved, with its wall time and TZID, and issues gets an unknown-time-zone warning. The mapping above then reads it in the zone you chose, like a floating time.

If you know what a feed's own names mean, say so with resolveTimeZone. The parser asks it first; undefined falls through to the built-in rules:

const { calendars, issues } = parseIcs(text, {
  resolveTimeZone: (tzid) => (tzid === "Our Office" ? "Europe/Lisbon" : undefined),
});

The API reference lists every rule, in order.

Issues

Each issue has a severity, a code, a message and, when it comes from one, a line. An error drops what it concerns, such as an event without DTSTART. A warning keeps a best-effort value.

Feeds you subscribe to break the rules more often than you'd think, so keep showing what parsed. Log the issues, as the page does, and if people should know, pass a count of the errors to the client and show it beside the calendar, such as "2 events couldn't be read".

Files that people upload are different: tell them what's wrong instead. strict: true throws an IcsParseError with the errors in issues:

import { IcsParseError, parseIcs } from "@moltenship/ics";

function readUpload(text: string) {
  try {
    return { calendars: parseIcs(text, { strict: true }).calendars };
  } catch (error) {
    if (!(error instanceof IcsParseError)) throw error;
    return { errors: error.issues.map((issue) => `Line ${issue.line ?? "?"}: ${issue.message}`) };
  }
}

A parser from createIcsParser is also a Standard Schema, so a form or router library that takes one can validate the upload. The API reference covers both.

Edit on GitHub

Last updated on

On this page