How to integrate add-to-calendar buttons to Webflow CMS events

How to integrate add-to-calendar buttons to Webflow CMS events

Learn how to build add-to-calendar buttons for Webflow CMS events with a Next.js route on Webflow Cloud.

How to integrate add-to-calendar buttons to Webflow CMS events

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Turn each Webflow CMS event into a downloadable .ics file with one Route Handler on Webflow Cloud and no API token.

Attendees who register on an event page and close the tab often forget the session. An add-to-calendar button fixes that by putting the event on their calendar while they are still on the page.

This tutorial builds that button for events stored in a Webflow CMS Collection. A small Next.js Route Handler on Webflow Cloud turns each event's fields into a valid iCalendar (.ics) file that Apple Calendar and Outlook open at the attendee's local time.

The button passes the event fields in its query string, so the app needs no API token or secret, and the route treats every value as untrusted.

What do you need to add add-to-calendar buttons in Webflow?

Adding an add-to-calendar button to your Webflow site requires combining a few Webflow CMS features with external runtime capabilities on Webflow Cloud.

Before setting up the Next.js Route Handler, ensure you have the following prerequisites ready:

  • Premium site plan with an Events Collection: The Collection holds a name, slug, location and description for each event in the Webflow CMS.
  • Webflow Cloud access: Needed to create a project and choose the path the app mounts at.
  • Next.js 15+ and Node.js 22+: Install both locally. Webflow's bring-your-own-app docs list Next.js 15 as the minimum, and Webflow's docs call for Node.js 22 or later.
  • npm: Webflow's docs state, "Currently, Webflow Cloud supports only the npm package manager," so install with npm rather than pnpm or Yarn.
  • Two calendar apps: For example, Apple Calendar and Outlook, so you can confirm the file opens at the right local time in both.

Webflow Cloud hosting starts on the free Starter plan, but Starter has no custom code, and Basic has no CMS, so Premium is the first plan with both, plus custom-domain app mounting.

5 steps to add add-to-calendar buttons in Webflow Cloud

The two CMS fields store times as offset-qualified strings, which removes the server-side time zone guess, and a Code Embed on the Events template links to the handler. Each step below ends with a check you can run before moving on.

1. Add UTC start and end fields to the Events Collection

Open the Events Collection's settings in the CMS, add two Plain text fields named Start UTC and End UTC, and save. Then fill both fields on every event item using ISO 8601 with an explicit offset: 2026-11-12T17:00:00Z for 5 p.m. UTC, or 2026-11-12T12:00:00-05:00 for the same moment written in New York winter time.

I keep the existing Date field for the visible date on the page and add these plain-text fields as the machine copy. The Date field formats a date for people, while these fields give the route one exact moment.

The Collection settings now list both new fields. The route needs one unambiguous string, and it will reject any value that lacks a Z or a numeric offset.

2. Create the Next.js app and mount it in Webflow Cloud

Scaffold a TypeScript Next.js 15 app with the App Router using npm.

Pin version 15, because @latest now installs Next.js 16:

npx create-next-app@15 calendar-app --ts --app --use-npm --yes
cd calendar-app
npm run dev

The local app runs at http://localhost:3000. Connect the app to a Webflow Cloud project for your site and set its mount path to /calendar. Leave next.config alone. Webflow injects the base path at build time, and Webflow's configuration docs say, "You don't need to add an adapter, a base path, or an output mode."

The app needs no environment variables, because every value it uses arrives with the request. Webflow Cloud should list the project with /calendar as its mount path, and the local dev server should show the default Next.js page.

3. Write the Route Handler that returns the .ics file

Create app/ics/route.ts. It reads the event fields from the query string, validates them, and builds the calendar file in memory before returning it directly.

The route is public and needs no secret. Anyone can build a link that makes your domain serve a calendar file with any text, so the handler limits field length and rejects bad times, including impossible dates such as February 30. The slug-only version under the next-steps heading closes that gap.

Route Handler implementation

Create the calendar handler with this code:

// app/ics/route.ts
import { NextRequest } from 'next/server';

const MAX = { slug: 100, title: 200, location: 200, details: 1000 };
const OFFSET_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
const encoder = new TextEncoder();

function clean(value: string | null, max: number): string {
  return (value ?? '').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').slice(0, max);
}

function escapeText(value: string): string {
  return value
    .replace(/\\/g, '\\\\')
    .replace(/;/g, '\\;')
    .replace(/,/g, '\\,')
    .replace(/\r?\n/g, '\\n');
}

function toIcsDate(value: string): string | null {
  if (!OFFSET_ISO.test(value)) return null;
  // Reject impossible calendar dates such as February 30, which Date would roll forward.
  const [y, m, d] = value.slice(0, 10).split('-').map(Number);
  const probe = new Date(Date.UTC(y, m - 1, d));
  if (probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return null;
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return null;
  return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}

function fold(line: string): string {
  const parts: string[] = [];
  let current = '';
  let currentBytes = 0;
  for (const char of line) {
    const size = encoder.encode(char).length;
    const limit = parts.length === 0 ? 75 : 74;
    if (currentBytes + size > limit) {
      parts.push(current);
      current = char;
      currentBytes = size;
    } else {
      current += char;
      currentBytes += size;
    }
  }
  parts.push(current);
  return parts.join('\r\n ');
}

export async function GET(request: NextRequest) {
  const params = request.nextUrl.searchParams;
  const slug = clean(params.get('slug'), MAX.slug).replace(/[^a-z0-9-]/gi, '');
  const title = clean(params.get('title'), MAX.title);
  const start = toIcsDate(params.get('start') ?? '');
  const end = toIcsDate(params.get('end') ?? '');

  if (!slug || !title || !start || !end || end <= start) {
    return new Response('Missing or invalid event fields', { status: 400 });
  }

  const location = clean(params.get('location'), MAX.location);
  const details = clean(params.get('details'), MAX.details);
  const stamp = toIcsDate(new Date().toISOString()) as string;

  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Events Site//Add to Calendar//EN',
    'CALSCALE:GREGORIAN',
    'METHOD:PUBLISH',
    'BEGIN:VEVENT',
    `UID:${slug}@${request.nextUrl.hostname}`,
    `DTSTAMP:${stamp}`,
    `DTSTART:${start}`,
    `DTEND:${end}`,
    `SUMMARY:${escapeText(title)}`,
    location ? `LOCATION:${escapeText(location)}` : '',
    details ? `DESCRIPTION:${escapeText(details)}` : '',
    'END:VEVENT',
    'END:VCALENDAR',
  ]
    .filter(Boolean)
    .map(fold);

  return new Response(lines.join('\r\n') + '\r\n', {
    headers: {
      'Content-Type': 'text/calendar; charset=utf-8',
      'Content-Disposition': `attachment; filename="${slug}.ics"`,
    },
  });
}

The handler ends each line with a carriage return and line feed and folds long lines. The folding counts bytes rather than characters, so accented titles fold correctly. Backslashes, semicolons, commas and newlines are escaped before they reach a text property.

The UID (the event's unique identifier) is built from the slug and hostname (slug@hostname), which keeps it stable for the same event across downloads. Change the PRODID text to your organization's name.

The file contains no export const runtime = 'edge' line, and you should not add one, because the OpenNext Cloudflare adapter Webflow Cloud uses does not support the Next.js edge runtime.

Testing the endpoint locally

Test the route locally with curl:

curl -i "http://localhost:3000/ics?slug=product-launch-webinar&title=Product%20launch%20webinar&start=2026-11-12T17:00:00Z&end=2026-11-12T18:00:00Z&location=Online"

The response should return status 200 and a text/calendar content type, with a body that starts at BEGIN:VCALENDAR and contains DTSTART:20261112T170000Z. Locally, the route sits at /ics. Once deployed, it sits at /calendar/ics.

4. Add the button to the Events template page

Place a Code Embed on the Events Collection template page where the button should appear, and paste in the markup below. The anchor stores each field in a data attribute. A short script then builds the link with URLSearchParams, so ampersands and spaces in a title are encoded before they reach the route.

Paste it as is for a first test.

The snippet does not escape attribute values, so keep double quotes out of the CMS fields it reads, as the FAQ explains:

<a class="atc-button" href="#"
   data-atc-slug="product-launch-webinar"
   data-atc-title="Product launch webinar"
   data-atc-start="2026-11-12T17:00:00Z"
   data-atc-end="2026-11-12T18:00:00Z"
   data-atc-location="Online"
   data-atc-details="Live walkthrough of the new release.">Add to calendar</a>
<script>
  (function () {
    var links = document.querySelectorAll('a.atc-button');
    links.forEach(function (link) {
      var d = link.dataset;
      var params = new URLSearchParams({
        slug: d.atcSlug || '',
        title: d.atcTitle || '',
        start: d.atcStart || '',
        end: d.atcEnd || '',
        location: d.atcLocation || '',
        details: d.atcDetails || ''
      });
      link.href = '/calendar/ics?' + params.toString();
    });
  })();
</script>

This markup works as pasted for the sample event, which is useful for a first test. Next, swap each sample value for its matching CMS field (Slug, Name, Start UTC, End UTC, location, description). Target the atc-button class to style the link to match your other buttons.

In the Code Embed editor, the data attributes now hold CMS fields instead of sample text.

After you save the embed, every event page carries a button whose markup holds that event's own field values.

5. Publish, deploy, and open the file in a calendar app

Deploy the app through Webflow Cloud, then publish the Webflow site so the template change goes live.

Test on whichever domain the Webflow Cloud environment is mounted to: open one event page, hover the button, and check that the link reads /calendar/ics?slug=... with encoded values. Click it. The browser should download a file named after the event slug, such as product-launch-webinar.ics.

Open that file in two calendar apps. Check the start time against your own time zone, and check the text fields for stray backslashes, which usually mean an escaping rule was changed in the handler.

The downloaded file should now open at the right local time in both calendar apps.

What causes add-to-calendar buttons on Webflow Cloud to fail?

Most failures come from the build or the mount path; the rest come from event times or headers that calendar apps reject. Test on the mounted domain as well as under next dev, because the build and mount-path failures only show up after deploy.

Webflow Cloud build fails on an edge runtime error

Cause: The bring-your-own-app page uses "edge" for two different things. The page describes deployment with the Edge runtime. That same page also still tells you to add export const runtime = 'edge' to API routes. The directive selects a separate, unsupported Next.js runtime target and breaks the build. The platform does not need it.

Fix: Delete the directive from app/ics/route.ts and from any other route file, then redeploy. If you later add middleware, keep it in middleware.ts. Webflow's framework docs state that Node.js runtime middleware isn't supported, and the proxy.ts rename in Next 16 runs on Node, so a renamed file cannot run on this platform.

The button returns a 404 on the published site but works under next dev

Cause: Changing the mount path can leave the embed pointing at the wrong route. The prefix in the embed script is plain text, so nothing rewrites it.

Fix: Compare the mount path in the Webflow Cloud project with the string in the embed script and make them identical, including the leading slash. The /calendar prefix in the script must match the mount path you set in Webflow Cloud exactly.

Remove any basePath entry from next.config. If you add client-side code to this app later, pass the mount path through NEXT_PUBLIC_BASE_PATH, because client-side fetch calls must include the base path manually.

The event lands at the wrong hour, or the route returns 400

Cause: The route returns 400 Missing or invalid event fields when a time lacks the required offset, when the end falls at or before the start, or when the slug or title is empty.

A wrong hour is subtler. It usually means an editor typed local wall-clock time followed by Z, or used a winter offset such as -05:00 for a summer date that needs -04:00. The route cannot detect either mistake, because both are valid moments in time.

Fix: Open the failing event in the CMS and read the raw Start UTC and End UTC values. For a 400, request the route with curl -i using those exact values to see which check fails. For a wrong hour, convert the intended local time to UTC once and store it with Z.

A single convention removes daylight saving errors from editors' hands. You can also note the expected format in each field's description for editors.

The calendar app rejects the file, or the browser shows raw BEGIN:VCALENDAR text

Cause: Missing Content-Type or Content-Disposition headers cause the browser to display the file as text, usually after someone returns a plain string or rewrites the handler's response. A file that opens as text or not at all can also mean someone edited the line endings, folding or escaping in the handler.

Fix: Run the curl -i request against the deployed route and check both headers. Pipe the body through od -c | head to confirm each line ends in \r\n. If a recent edit touched escapeText or fold, restore the original escapeText and fold functions in app/ics/route.ts. Then retest in both calendar apps.

What you can build next with add-to-calendar buttons and Webflow

I would add an RSVP endpoint that accepts a form post from the event page. It could confirm attendees' seats and tell them what changed. The same Webflow Cloud app can carry both jobs, since another Route Handler beside app/ics/route.ts shares the /calendar mount.

As built, the query-string route will serve whatever event text someone puts in a crafted link from your domain. A slug-only route closes that gap by reading the event from the cached Content Delivery API, which returns DateTime fields as ISO 8601 strings, so the button sends only the slug, and the route looks up everything else.

For a scheduling build that goes further, the calendar booking guide connects Google Calendar availability to a Webflow form.

For the token that slug-only route would need, Webflow's developer docs explain how Webflow Cloud makes secret variables available at build and runtime while redacting them from build logs.

Frequently asked questions

How long does a CMS edit take to reach the downloaded .ics file?

You must publish after an edit to an event's times or title, because the button reads values from the published page. Every field travels in the query string, so changed values change the request URL, and Webflow Cloud sends every response as private, no-cache, so the next click after you republish returns the updated file.

Can one page show several add-to-calendar buttons, such as an events list?

Yes. The script runs document.querySelectorAll('a.atc-button') and sets the href on every matching anchor. Inside a Collection List, each item needs its own anchor carrying that event's data-atc-* values. The script only has to run once per page, so one copy of the script tag covers every anchor, however many events the list shows.

What happens if an event title contains a double quote?

You can break a data-atc-* attribute by inserting an unescaped CMS value: a double quote can end it early, leaving the anchor without its remaining attributes. Use single or typographic quotes in the Name, location and description fields, then test with the curl request. The route escapes commas and semicolons but cannot repair broken markup.

Will an attendee who downloads the file again after a time change get a duplicate event?

It depends on the calendar app. The stable UID alone does not determine how the app handles another download. The file has no SEQUENCE line. I recommend you test a re-download in both of your calendar apps. Avoid changing a slug after publishing; a new slug produces a new UID.


Last Updated
October 30, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.