Show an ICS feed
Parse a Google, Outlook or iCloud feed on the server and show its events in the calendar.
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/icsIt 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:
// 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"withcacheLife("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 } }tofetchinstead. - Issues.
parseIcsdoesn't throw on bad data. It keeps what it can and lists each problem inissues, with its line. The page logs them; see Issues for more. - The current time.
connection()renders the calendar on each request, sonowis 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:
// 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:
// 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) ?? []);
}kind | Written as | Shown as |
|---|---|---|
utc | DTSTART:20261012T090000Z | A timed event at that instant |
zoned | DTSTART;TZID=Europe/Berlin:… | A timed event at zoned.toInstant() |
date | DTSTART;VALUE=DATE:20261012 | An all-day event |
floating | DTSTART:20261012T090000 | A timed event at that wall time, in a zone you choose |
unresolved | DTSTART;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 ascalendar.timeZone.feedTimeZoneuses 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.
getEventEndreturns 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.toFeedEventskips 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
exceptionDatesremoves 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.
Last updated on