ics: generating iCalendar files from Node without touching the format
iCalendar (ics) file generator for node.js
At a glance
- What is it?
- The ics package turns a plain object into an RFC 5545 VCALENDAR string, handling the awkward parts of the format, namely UTC conversion, comma escaping, line folding and the parameter syntax on attendee and organizer lines. It also has an async variant that yields to the event loop, which is what you want for a large batch.
- Who is it for?
- ics suits a Node or browser project that needs to offer a calendar file download or a calendar subscription, because the escaping, folding and UTC handling in RFC 5545 are exactly the details that are tedious to get right by hand. It does not suit you if you need to read calendars back, since the package is a generator only, and it does not suit a project that needs recurring events with the full RRULE surface, since the documentation does not cover that.
- Can I use it commercially?
- Yes. ISC is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 22 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One object in, one VCALENDAR string out
The ics package is described as the iCalendar generator, implementing the format specified in RFC 5545, and it is published on npm as ics.
The whole API surface is small. There is createEvent(attributes[, callback]) which generates an iCal-compliant VCALENDAR string, and createEvents for multiple events. The event object is plain JavaScript:
const ics = require('ics')
// or, in ESM: import * as ics from 'ics'
const event = {
start: [2018, 5, 30, 6, 30],
duration: { hours: 6, minutes: 30 },
title: 'Bolder Boulder',
description: 'Annual 10-kilometer run in Boulder, Colorado',
location: 'Folsom Field, University of Colorado (finish line)',
url: 'http://www.bolderboulder.com/',
geo: { lat: 40.0095, lon: 105.2669 },
categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'],
status: 'CONFIRMED',
busyStatus: 'BUSY',
organizer: { name: 'Admin', email: '[email protected]' },
attendees: [
{ name: 'Adam Gibbons', email: '[email protected]', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' },
{ name: 'Brittany Seaton', email: '[email protected]', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' }
]
}
ics.createEvent(event, (error, value) => {
if (error) {
console.log(error)
return
}
console.log(value)
})The start field is an array rather than a Date, and the array is year, month, day, hour, minute. The duration is an object of hours and minutes. Both choices are deliberate, because the package has no date library in its dependencies, and a Date would drag the local timezone into the output.
The result is a string. The callback receives an error and the value, and the value is the finished VCALENDAR text, which you can write to disk, hand to a download link, or inspect.
The generated output shows what the escaping does for you
The README prints the full generated calendar alongside the example, which is the most useful part of the documentation because it shows exactly what the object fields become.
// BEGIN:VCALENDAR
// VERSION:2.0
// CALSCALE:GREGORIAN
// PRODID:adamgibbons/ics
// METHOD:PUBLISH
// X-PUBLISHED-TTL:PT1H
// BEGIN:VEVENT
// UID:S8h0Vj7mTB74p9vt5pQzJ
// SUMMARY:Bolder Boulder
// DTSTAMP:20181017T204900Z
// DTSTART:20180530T043000Z
// DESCRIPTION:Annual 10-kilometer run in Boulder\, Colorado
// X-MICROSOFT-CDO-BUSYSTATUS:BUSY
// URL:http://www.bolderboulder.com/
// GEO:40.0095;105.2669
// LOCATION:Folsom Field, University of Colorado (finish line)
// STATUS:CONFIRMED
// CATEGORIES:10k races,Memorial Day Weekend,Boulder CO
// ORGANIZER;CN=Admin:mailto:[email protected]
// ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Adam Gibbons:mailto:[email protected]
// ATTENDEE;RSVP=FALSE;ROLE=OPT-PARTICIPANT;DIR=https://linkedin.com/in/brittanyseaton;CN=Brittany
// Seaton:mailto:[email protected]
// DURATION:PT6H30M
// END:VEVENT
// END:VCALENDARThree things stand out. The description has its comma escaped, so Annual 10-kilometer run in Boulder, Colorado becomes Annual 10-kilometer run in Boulder\, Colorado, which is the rule RFC 5545 requires and which is exactly the sort of detail people get wrong. The attendee lines carry parameters, with RSVP, ROLE and PARTSTAT before the CN and mailto parts, and the second attendee's line is folded across two lines with the leading whitespace, which is the format's line folding for long values. And the categories array becomes a single comma separated value with no space after the commas.
The busyStatus field ends up as a Microsoft specific property, X-MICROSOFT-CDO-BUSYSTATUS:BUSY, rather than a standard one, and the header carries an X-PUBLISHED-TTL of PT1H, which tells consumers how long a published calendar should be trusted before being refetched.
Writing the file, and the browser case that needs a different approach
On the server, writing the file is a matter of calling writeFileSync with the value:
const { writeFileSync } = require('fs')
const ics = require('ics')
ics.createEvent({
title: 'Dinner',
description: 'Nightly thing I do',
busyStatus: 'FREE',
start: [2018, 1, 15, 6, 30],
duration: { minutes: 50 }
}, (error, value) => {
if (error) {
console.log(error)
}
writeFileSync(`${__dirname}/event.ics`, value)The README includes a comment in that example explaining why the pattern differs in the browser, since you cannot use fs in frontend libraries like React and should import a module to save files to the browser instead, using saveAs from file-saver with a Blob of type text/calendar.
The ESM and browser example takes that further and shows the full dance. The import is named, import { createEvent} from 'ics'. The callback is wrapped in a Promise that resolves a new File constructed from the value with the filename and the text/calendar type. Then URL.createObjectURL makes an object URL for it, and the comment explains why it is not simply assigned to a window, because trying to assign the file URL to a window could cause cross-site issues, so this is a workaround using HTML5. The workaround itself is an anchor element with the href and download attributes, appended to the document body, clicked, removed again, and then URL.revokeObjectURL to release the object URL.
That sequence is worth copying rather than reinventing, because the two details that are easy to miss are the append and remove of the anchor, which some browsers require for the click to register, and the revokeObjectURL call, which without it leaks the blob for the lifetime of the page.
Batches, the async variant, and the dependencies behind them
For more than one event, createEvents takes an array and returns an error and a value directly, with no callback:
const ics = require('./dist')
const { error, value } = ics.createEvents([
{
title: 'Lunch',
start: [2018, 1, 15, 12, 15],
duration: { minutes: 45 }
},
{
title: 'Dinner',
start: [2018, 1, 15, 12, 15],
duration: { hours: 1, minutes: 30 }
}
])The async variant exists for the case where the list is genuinely large. createEventsAsync returns a Promise that resolves to { error, value }, and there is no callback form. While building the calendar it yields to the event loop periodically, which helps keep huge event lists from blocking Node or the browser for too long. That is the whole reason it exists, and it is a real concern: a calendar with tens of thousands of events is a synchronous string build that will freeze a browser tab.
An empty event list and optional calendar header work the same way with the async variant:
createEventsAsync([], { calName: 'My calendar' }).then(({ error, value }) => {
if (error) console.log(error)
else console.log(value)
})The dependency list is short and gives a hint about how the package validates its input. yup at 1.2.0 is a schema validation library, which is how the event attributes are checked before the string is built, and nanoid 3.1.23 generates the UID, which is why the UID in the output looks like a random string rather than a hash of the event. runes2 1.1.2 is a string library. The validation with yup is the part worth knowing about, because it means a malformed event produces a structured error rather than a broken calendar file.
The build is Babel and Gulp, with the test suite on Mocha and Chai, and the test and start scripts both set TZ=utc, which tells you the tests care about the timezone the output is generated in.
TypeScript types, module resolution, and what the package does not do
A few packaging details decide how the package behaves in your build, and they are visible in the manifest.
The exports field is the modern form and it is minimal. The types entry points at ./index.d.ts and the default at ./dist/index.js, with main and types also declared separately for older tooling. The files array lists only dist/ and index.d.ts, so the published tarball contains the built output and the type declarations and nothing else, no src and no tests. That is a clean package boundary, and it means consumers cannot reach into the implementation.
The type declarations are hand-maintained rather than generated, since the build script is just rm -r dist and gulp build with no type generation step, and the repository has an index.d.ts at the root alongside a src/ directory. For a TypeScript user, that file is the contract, and it is worth reading before you pass an event object, because a wrongly typed field fails at compile time rather than turning into a silently malformed calendar.
The tests run on Mocha with Chai, and both the test and the start script set TZ=utc before running, which tells you the suite is checking the output in a specific timezone. The start script additionally runs in watch mode with --recursive, so a developer gets reload on save.
What the package does not do is read calendars. It is a generator, and there is no parsing counterpart, so if your application needs to consume existing .ics files as well as produce them, you need a second library. The README also does not document recurring event support, and the API section shown ends at createEvent with the promise of more in the full documentation.
Two operational notes for a server deployment. The UID is generated with nanoid, so it is random rather than derived from the event, which means the same event exported twice gets two different UIDs and calendar clients will treat them as two entries. And the package does no timezone conversion of its own, because it has no date library, so the start array is in whatever timezone you intend and you need to be explicit about it.
Editorial conclusion
ics suits a Node or browser project that needs to offer a calendar file download or a calendar subscription, because the escaping, folding and UTC handling in RFC 5545 are exactly the details that are tedious to get right by hand. It does not suit you if you need to read calendars back, since the package is a generator only, and it does not suit a project that needs recurring events with the full RRULE surface, since the documentation does not cover that. Before you adopt it, read the generated output in the README example rather than trusting the object shape, because fields such as start, duration and the output type determine the DTSTART line, and note that the package has three runtime dependencies and no date library, so you supply the date parts yourself.
Frequently asked questions
How do I install the ics package and create an event?
Run npm install -S ics, then require the package and call ics.createEvent with an event object and a callback. The start field is an array of year, month, day, hour and minute, and the duration is an object of hours and minutes.
How do I create an iCalendar file on the server with ics?
Call ics.createEvent with a callback and write the value to disk with writeFileSync from the fs module, for example writeFileSync(`${__dirname}/event.ics`, value). In a browser you cannot use fs and should build a Blob of type text/calendar and download it instead.
When should I use createEventsAsync instead of createEvents?
For large batches. createEventsAsync returns a Promise resolving to { error, value } with no callback form, and it yields to the event loop periodically while building the calendar, which keeps huge event lists from blocking Node or the browser for too long.
Does the ics package support alarms?
Yes. The README shows an example building an event with an alarms array containing an audio action with a trigger of hours and minutes before the start, a repeat count and an attachType of VALUE=URI, which is the Mac oriented form of the example.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/adamgibbons-ics)Was this useful?