Arsalan Khattak
23 September 2025

Creating a custom Bryntum Calendar React component for Storyblok

Bryntum Calendar is a powerful and flexible JavaScript scheduling component that easily integrates with all major JavaScript frontend frameworks. This […]

Bryntum Calendar is a powerful and flexible JavaScript scheduling component that easily integrates with all major JavaScript frontend frameworks.

This guide demonstrates how to add the Bryntum Calendar React component to a Storyblok project. Storyblok is a headless CMS known for its real-time visual editing capabilities.

We’ll do the following:

Here’s a preview of what you’ll build:

You can find the code for the completed tutorial in this GitHub repository.

Creating a Next.js Bryntum Calendar project

We’ll use the Bryntum Calendar Next.js with TypeScript starter template. Follow the instructions in the template’s README file to install the required dependencies.

The Calendar component located in components/Calendar.tsx is a React Client Component, as indicated by the "use client" directive at the top of the file.

The Calendar configuration, which is stored in the calendarProps variable, uses the Bryntum Calendar’s CrudManager to load example event data from a local data.json file.

The code inside the useEffect function shows how to access the Bryntum Calendar instance.

To ensure compatibility with Next.js, the Bryntum Calendar React component is dynamically imported in components/CalendarWrapper.tsx with server-side rendering (SSR) disabled. This is required because Bryntum components are client-side only.

Getting started with Storyblok

Sign up to Storyblok to get a 14-day free trial.

The Storyblok Visual Editor lets users manage and organize website content without writing code. We’ll create React components for Storyblok that you can drag and drop in the visual editor, while giving developers fine control over how content is rendered.

In Storyblok, a space is where all content related to a project is kept. The content of a page is stored as a story, made up of content entries defined by a content type, such as “Article page”. Content types have various data fields, such as text, number, boolean, and asset. A field can also be a block type. A Storyblok block is a reusable content component.

There are three types of blocks (components): content type, nestable, and universal. Nestable blocks are nested within a story or other block, for example, “Chapter”, “Section”, or “Full Width Image”. Because nestable blocks are part of a story, you can’t create a story from this block type. A universal block can be used as a content type block and a nestable block at the same time.

Storyblok gets its name from this component structure.

Creating a Storyblok space

Once you’ve logged in to Storyblok, select Explore Demo in the Get started with Storyblok section to open a demo space and take a quick guided tour.

After exploring the demo, open the Spaces page by clicking the Your demo space link at the top left. You’ll see the demo space in the My Spaces section. Click the + Add Space button at the top right to create a new space.

For this tutorial, we’ll use the Starter plan. Click the Continue with Starter button.

Name your space “bryntum-calendar” and click the Create a Space button.

A Quickstart card will appear for your new space.

Open the Content page from the left sidebar. You’ll see that a “Home” story has been created automatically. We’ll use this story. Click on it to open the Visual Editor.

In the right panel of the visual editor, you can see the example content that’s been added to the story.

Copy the access token displayed in the content preview.

In your Next.js project, create a .env.local file and add the access token as a NEXT_PUBLIC_STORYBLOK_API_TOKEN environment variable.

Now click Blocks in the left navigation bar to open the Block library. Note the four example blocks added to the story. Hover over a block to reveal a three-dot menu on the right-hand side. Click the three dots to open the menu, and select Delete to delete the block. Use this method to delete the feature, grid, and teaser blocks.

Creating Bryntum Calendar Storyblok blocks

We’ll create four blocks for the Bryntum Calendar page: Header, Calendar, Event, and Resource.

In the Block library, click the + New Block button at the top right. Set the Technical name to “header”, choose Nestable block as the type, and click Add Block.

Click the header block in the block library to open the block editor. Add a field named “title”, set its type to Text, and save.

Create another nestable block called “resource”. This block stores the Bryntum Calendar resources data. Add the following fields based on the Bryntum Calendar ResourceModel fields.

Field NameField Type
idText
nameText
eventColorText
readOnlyBoolean

In the “resource” block editor, click the readOnly list item in the General tab to open the Edit field tab and toggle on the Default value switch.

Next, create a nestable block called “event”. This block will store the Bryntum Calendar event data. Add the following fields based on the Bryntum Calendar EventModel fields.

Field NameField Type
idText
nameText
readOnlyBoolean
resourceIdText
draggableBoolean
resizableBoolean
allDayBoolean
startDateDate/Time
endDateDate/Time
exceptionDatesText
recurrenceRuleText
clsText
eventColorText
eventStyleText
iconClsText
styleText

For the draggable and resizable fields, toggle on the Default value switch.

For simplicity, we’ve excluded some fields and other Bryntum Calendar data stores like assignments and dependencies.

Next, create a “calendar” block with a Universal block type. Add two fields of type Blocks, one named “resources” and one named “events”. Click Save.

Close the Block library modal.

In the Page block panel, delete the Teaser and Grid blocks from the Body field.

Click the Save button at the top right of the page.

Adding the Storyblok components to a page

We’ll now add the Storyblok components for a Bryntum Calendar to your page component and populate them with content.

First, click the Add Block button in the Body field of the page component and insert a Header block.

Enter “Team Hackathon Calendar” into the header block’s Title field, then click Save at the top right.

Add a Calendar block below the header block. Inside the Calendar block’s Resources field, add three Resource blocks with the following data:

Field NameResource 1Resource 2Resource 3
idbryntumhotelmichael
nameBryntum teamHotel ParkMichael Johnson
eventColorblueorangedeep-orange

Click Save.

Next, add three Event blocks to the Events field of the Calendar block using this data:

Field NameEvent 1Event 2Event 3
id123
nameBreakfastRoadmapping for 2026Check-Out & Fly home
resourceIdhotelbryntummichael
startDate2025-10-21 09:002025-10-22 10:002025-10-24 10:00
endDate2025-10-21 10:002025-10-22 12:002025-10-24 12:00

Click Save.

Setting up a dev server

Since Storyblok V2, you need to set up your dev server with an HTTPS proxy to enable a secure connection for the application. We’ll use port 3010, so your Next.js app will be accessible at https://localhost:3010.

Follow the Storyblok guide for your operating system to set up an HTTPS proxy:

Next, set the Visual Editor to open at your HTTPS proxy URL. Exit the visual editor by hovering over the Storyblok logo at the top left and clicking the back button that appears. Open the Settings menu for your “bryntum-calendar” space, go to the Visual Editor configuration, and set the Location to https://localhost:3010. Click Save at the top right.

Setting the real path

To display your Next.js app in the Storyblok Visual Editor, you’ll need to change the real path. The real path determines where the visual editor opens if the location is different from the default “home” slug.

Go to the Content page and click on the Home story to open the visual editor. Click Config in the menu on the right, set Real path to “/”, and save.

Click Edit in the menu on the right. Make sure both your Next.js development server and HTTPS proxy are running.

You should now see the example Bryntum Calendar in the visual editor.

At this point, the Bryntum Calendar isn’t using Storyblok data yet. Next, you’ll connect Storyblok to your Next.js app to fetch the story data, and then create React components to render it.

Connecting Storyblok to the Next.js project

First, install the official Storyblok React SDK.

npm install @storyblok/react

This React plugin allows you to interact with the Storyblok API and enables real-time visual editing.

In the app/components folder, create a file called StoryblokProvider.tsx and add the following code:

'use client';
import { apiPlugin, storyblokInit } from '@storyblok/react';
import { ReactNode } from 'react';

storyblokInit({
    accessToken : process.env.NEXT_PUBLIC_STORYBLOK_API_TOKEN,
    use         : [apiPlugin]
});

export default function StoryblokProvider({
    children
}: {
  children: ReactNode;
}) {
    return children;
}

The storyblokInit function initializes the connection to Storyblok. Note that if your Storyblok space server location is set to a region other than Europe, you’ll need to add a region parameter to the storyblokInit function call to set the correct region.

Import StoryblokProvider into app/layout.tsx:

import StoryblokProvider from "./components/StoryblokProvider";

Wrap the returned React elements in StoryblokProvider:

return (
    <StoryblokProvider>
        <html lang="en" className={poppins.className}>
            <body>
                {children}
            </body>
        </html>
    </StoryblokProvider>
);

This wrapper ensures the Storyblok components are loaded client-side so you can see real-time updates. The Bryntum Calendar is also client-side only.

Fetching the story data

The next step is to fetch your story data from Storyblok and enable real-time editing in the Visual Editor.

The useStoryblok hook fetches story content and enables live editing by automatically updating when content changes in the Visual Editor.

We’ll store the story data in React context. Let’s create the provider.

Create a contexts folder in app. In contexts, create a StoryData.context.tsx file and add the following lines of code to it:

'use client';

import { Page } from '@/.storyblok/types/storyblok-components';
import { getStoryblokApi } from '@storyblok/react';
import { createContext, Dispatch, SetStateAction, useState } from 'react';

export type Story =  {
    content: Page;
    [index: string]: unknown;
  };

export const StoryDataContext = createContext({
    storyData       : {} as Story,
    setStoryData    : (() => {}) as Dispatch<SetStateAction<Story>>,
    invalidateCache : (() => {}) as () => void,
    refreshStory    : (() => {}) as () => void,
    cacheKey        : 0 as number
});

export default function StoryDataProvider({
    children
}: {
  children: React.ReactNode;
}) {
    const [storyData, setStoryData] = useState<Story>({} as Story);
    const [cacheKey, setCacheKey] = useState(0);

    const invalidateCache = () => {
        const storyblokApi = getStoryblokApi();
        storyblokApi.flushCache();
    };

    const refreshStory = () => {
        invalidateCache();
        setCacheKey(Date.now());
    };

    return (
        <StoryDataContext.Provider
            value={{
                storyData,
                setStoryData,
                invalidateCache,
                refreshStory,
                cacheKey
            }}
        >
            {children}
        </StoryDataContext.Provider>
    );
}

The refreshStory function invalidates the Storyblok API cache and sets the cache version to the current time, ensuring you always get fresh data. You can learn more about caching in this Storyblok article: How stories are cached in the Content Delivery API.

In app/layout.tsx, import the story data context provider:

import StoryDataProvider from "./contexts/StoryData.context";

In the previously added StoryblokProvider, wrap the returned HTML in StoryDataProvider:

return (
    <StoryblokProvider>
        <StoryDataProvider>
            <html lang="en" className={poppins.className}>
                <body>
                    {children}
                </body>
            </html>
        </StoryDataProvider>
    </StoryblokProvider>
);

Replace the code in app/page.tsx file with the following:

'use client';

import { Page } from '@/.storyblok/types/storyblok-components';
import { StoryblokComponent, useStoryblok } from '@storyblok/react';
import { useContext, useEffect } from 'react';
import { Story, StoryDataContext } from './contexts/StoryData.context';
import { CustomEventModel } from './lib/CustomEventModel';

export default function Home() {
    const { setStoryData, cacheKey } = useContext(StoryDataContext);
    const story = useStoryblok('/home', { version : 'draft', cv : cacheKey });

    useEffect(() => {
        if (!story?.content) return;

        const transformedStory: Story = {
            ...story,
            content : {
                ...story.content,
                component : 'page',
                _uid      : story.content._uid || '',
                body      : story.content.body?.map((block: Page) => {
                    if (block.events) {
                        return {
                            ...block,
                            events : (block.events as CustomEventModel[]).map((event: CustomEventModel) => {
                                event.exceptionDates = event?.exceptionDates ?
                                    (typeof event.exceptionDates === 'string' ?
                                        Object.keys(JSON.parse(event.exceptionDates)) :
                                        event.exceptionDates) :
                                    [];
                                // Filter out empty string fields
                                return Object.fromEntries(
                                    Object.entries(event).filter(([, value]) => value !== '')
                                );
                            })
                        };
                    }
                    return block;
                })
            }
        };

        setStoryData(transformedStory);
    }, [story, setStoryData]);

    if (!story || !story.content) {
        return <div>Loading...</div>;
    }

    return <StoryblokComponent blok={story.content} />;
}

The useStoryblok hook fetches the story data and takes two arguments: the story slug and API options (here we’re fetching the "draft" version of the content). The cv is the cache version. By default, the Storyblok React SDK client reuses the cached value for Storyblok API calls so that cached data is returned from the Storyblok CDN, which saves on API calls and improves performance. We’ll prevent caching of API responses by providing new cv values for each API call.

The story data has empty string fields filtered out from the events and resources data, and then the storyData is stored in context using setStoryData.

We pass the story data as a prop to StoryblokComponent, which dynamically renders the appropriate React components.

Creating React components for the Storyblok blocks

Next, let’s create React components for the Storyblok blocks.

In your components folder, create a Page.tsx file and add the following code:

import { Page as PageType } from '@/.storyblok/types/storyblok-components';
import { SbBlokData, StoryblokComponent, storyblokEditable } from '@storyblok/react';

const Page = ({ blok }: { blok: PageType & SbBlokData }) => {
    return (
        <main {...storyblokEditable(blok)}>
            {blok.body?.map((nestedBlok) => (
                <StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
            ))}
        </main>
    );
};

export default Page;

The storyblokEditable function links editable components to the Storyblok Visual Editor for inline editing. The StoryblokComponent dynamically renders the Storyblok blocks.

Now, in the same folder, create a Header.tsx file:

import { Header as HeaderType } from '@/.storyblok/types/storyblok-components';
import { SbBlokData, storyblokEditable } from '@storyblok/react';

const Header = ({ blok }: { blok: HeaderType & SbBlokData }) => {
    return (
        <h2 {...storyblokEditable(blok)} style={{ padding : '1rem' }}>
            {blok.title}
        </h2>
    );
};

export default Header;

Next, create a CalendarSb.tsx file:

import { SbBlokData, storyblokEditable } from '@storyblok/react';
import { Calendar } from '@/.storyblok/types/storyblok-components';
import { CalendarWrapper } from './CalendarWrapper';

const CalendarSb = ({ blok }: { blok: Calendar & SbBlokData }) => {
    return (
        <div {...storyblokEditable(blok)} style={{ flex : 1 }}>
            <CalendarWrapper />
        </div>
    );
};

export default CalendarSb;

The Bryntum Calendar is rendered using the CalendarWrapper component.

Finally, make sure to import these components into your components/StoryblokProvider.tsx file, and register them in the components object for storyblokInit:

import Page from "./Page";
import Header from "./Header";
import CalendarSb from "./CalendarSb";

const components = {
    page     : Page,
    header   : Header,
    calendar : CalendarSb
};

storyblokInit({
    accessToken : process.env.NEXT_PUBLIC_STORYBLOK_API_TOKEN,
    use         : [apiPlugin],
    components
});

Adding Storyblok types to components

The Storyblok components you created use types from a component-types-sb.d.ts file generated from your Storyblok schema. To generate these types, you’ll use the Storyblok CLI.

First, install the Storyblok CLI globally:

npm i -g storyblok

Log in to Storyblok via the CLI:

storyblok login

Add the following scripts to your package.json (replace ‎285757208479996 with your actual Storyblok space ID):

"pull-sb-components": "storyblok components pull --space 285757208479996",
"generate-sb-types": "storyblok types generate --space 285757208479996"

Find your space ID in the Settings page:

Download the schema of your Storyblok components:

npm run pull-sb-components

A components.json file will appear in the .storyblok directory.

Now generate TypeScript types based on the downloaded schema:

npm run generate-sb-types

This will create a types folder in .storyblok. Move the generated storyblok-components.d.ts file up one level to the types folder, then delete the folder named after your space ID.

⚠️ Whenever you make changes to your blocks in Storyblok, rerun the pull-sb-components and generate-sb-types scripts to update your TypeScript types.

Loading the Storyblok story data into the Bryntum Calendar

Now, let’s load the Storyblok story data into the Bryntum Calendar using your custom models.

First, update app/calendarConfig.ts as follows:

import { BryntumCalendarProps } from '@bryntum/calendar-react';
import { CustomEventModel } from './lib/CustomEventModel';
import { CustomResourceModel } from './lib/CustomResourceModel';

const calendarProps: BryntumCalendarProps = {
    date    : new Date(2025, 9, 20),
    project : {
        eventStore : {
            modelClass : CustomEventModel
        },
        resourceStore : {
            modelClass : CustomResourceModel
        }
    }

};

export { calendarProps };

Here, CustomEventModel and CustomResourceModel are used to add Storyblok-specific fields to the Bryntum Calendar events and resources data.

In app, create a lib folder. In lib, create a CustomEventModel.ts file:

import { EventModel } from '@bryntum/calendar';

export interface CustomEventModelType extends EventModel {
  _editable?: string;
  component?: string;
  _uid?: string;
}

export class CustomEventModel extends EventModel implements CustomEventModelType {
    static get fields() {
        return [
            { name : 'component', type : 'string', defaultValue : '' },
            { name : '_uid', type : 'string', defaultValue : '' },
            { name : '_editable', type : 'string', defaultValue : '' }
        ];
    }
}

And create a CustomResourceModel.ts file:

import { ResourceModel } from '@bryntum/calendar';

export interface CustomResourceModelType extends ResourceModel {
    _editable?: string;
    component?: string;
    _uid?: string;
}

export class CustomResourceModel extends ResourceModel {
    static get fields() {
        return [
            { name : 'component', type : 'string', defaultValue : '' },
            { name : '_uid', type : 'string', defaultValue : '' },
            { name : '_editable', type : 'string', defaultValue : '' }
        ];
    }
}

These custom models ensure that fields like ‎_uid and ‎component are available to Bryntum Calendar and mapped from Storyblok data.

Now, update your ‎app/components/Calendar.tsx file to connect the Bryntum Calendar to Storyblok data via React context and your custom models.

Replace the code in app/components/Calendar.tsx with:

'use client';

import { BryntumCalendar } from '@bryntum/calendar-react';
import { useContext, useEffect, useRef } from 'react';
import { StoryDataContext } from '../contexts/StoryData.context';
import { calendarProps } from '../calendarConfig';
import { CustomEventModelType } from '../lib/CustomEventModel';
import { CustomResourceModelType } from '../lib/CustomResourceModel';

export default function Calendar() {
    const { storyData, setStoryData, refreshStory, invalidateCache } = useContext(StoryDataContext);
    const currCalendarComponentIndex = storyData?.content?.body?.findIndex(
    (item) => item.hasOwnProperty('events')
);

    const calendarRef = useRef<BryntumCalendar>(null);

    useEffect(() => {
        // Bryntum Calendar instance
        const calendar = calendarRef?.current?.instance;
    }, []);

    return (
        <BryntumCalendar
            ref={calendarRef}
            events={currCalendarComponentIndex !== undefined ? (storyData?.content?.body?.[currCalendarComponentIndex]?.events as CustomEventModelType[]) || [] : []}
            resources={currCalendarComponentIndex !== undefined ? (storyData?.content?.body?.[currCalendarComponentIndex]?.resources as CustomResourceModelType[]) || [] : []}
            {...calendarProps}
        />
    );
}

Here, we get the story data from the StoryDataContext provider using the useContext hook. We then bind the events and resources data to the Bryntum Calendar React component. When the storyData state changes, the Bryntum Calendar will be updated.

We use the currCalendarComponentIndex variable to find the Calendar component in the story data, assuming that there’s only one Calendar in the story.

Finally, update your global styles to ensure the Bryntum Calendar displays correctly.

Replace the styles in the app/globals.css file with the following:

* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
}

#b-calendar-1 {
  display: flex;
  flex-direction: column;
  height: 100vh;
  font-size: 14px;
}

With these styles in place, the Bryntum Calendar will fill the viewport and maintain a consistent appearance.

At this point, when you make changes to the story content in the Visual Editor, you’ll see those changes reflected in the Bryntum Calendar:

However, updates made directly in the Bryntum Calendar (such as adding, editing, or deleting events/resources) won’t yet persist back to Storyblok. The next step is to enable two-way syncing by connecting the Bryntum Calendar’s data changes to the Storyblok Management API.

Updating Storyblok story data from the Bryntum Calendar

You’ll use the Storyblok Management API to update the Storyblok story data from the Bryntum Calendar, and you need a personal access token to authenticate. To create it, go to the Personal access token tab in the Storyblok account page and click Generate new token.

Add the following environment variables to your Next.js app’s .env.local file:

STORYBLOK_BRYNTUM_CALENDAR_SPACE_ID=
STORYBLOK_BRYNTUM_CALENDAR_STORY_ID=
STORYBLOK_PERSONAL_ACCESS_TOKEN=

Your story ID will appear in the data returned from the Storyblok API.

Creating a Next.js API route to update data

To update the Storyblok story from the Bryntum Calentar, you need to create a Next.js API route.

In app, create an api folder. In api, create an update folder. In update, create a route.ts file and add the following PUT request route handler:

export async function PUT(request: Request): Promise<Response> {
    const reqBody = await request.json();
    try {
        const requestPayload = {
            story        : reqBody.story,
            force_update : 1,  // Force update to avoid conflicts
            publish      : 1 // Auto-publish when working with published version
        };

        const res = await fetch(
      `https://mapi.storyblok.com/v1/spaces/${process.env.STORYBLOK_BRYNTUM_CALENDAR_SPACE_ID}/stories/${process.env.STORYBLOK_BRYNTUM_CALENDAR_STORY_ID}`,
      {
          method  : 'PUT',
          headers : {
              Authorization  : process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN || '',
              'Content-Type' : 'application/json'
          },
          body : JSON.stringify(requestPayload)
      }
        );
        const data = await res.json();

        return Response.json(data);
    }
    catch (error) {
        console.error('Loading events and resources data failed', error);
        return new Response('Loading events and resources data failed', {
            status : 500
        });
    }
}

You are using the Storyblok Management API to update the Storyblok story. In the request body properties, set force_update and publish to 1. This will force update the story and publish it immediately.

Updating the Bryntum Calendar component

Next, let’s set up the Bryntum Calendar to send event and resource updates to the API route. We’ll use the Bryntum Calendar onDataChange event to detect when the store data has changed.

In app/components/Calendar.tsx, add the following onDataChange property to the BryntumCalendar component:

onDataChange = { syncData }

Add the following syncData function in the Calendar component:

const syncData: SyncData = ({ store, action, record, records }) => {
    const storeId = store.id;

    if (storeId === 'events') {
        const eventRecord = record as CustomEventModelType;

        if (action === 'remove') {
            const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
            const content = storyDataState.content;
            const updatedContent = content?.body?.map((item) => {
                if (item.component === 'calendar') {
                    records.forEach((evt) => {
                        item.events = item.events?.filter(
                            (event) => event.id !== evt.id
                        );
                    });
                }
                return item;
            });
            const updatedStory = {
                story : {
                    ...storyDataState,
                    content : {
                        ...storyData.content,
                        body : updatedContent
                    }
                }
            };
            updateStory(updatedStory);
        }

        if (action === 'update') {
            const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
            const content = storyDataState.content;
            const updatedContent = content.body?.map((item) => {
                if (item.component === 'calendar') {
                    const existingEventIndex = item.events?.findIndex(
                        (event) => event.id === eventRecord.id
                    );

                    const eventData = {
                        id             : existingEventIndex >= 0 ? eventRecord.id : crypto.randomUUID(),
                        _uid           : existingEventIndex >= 0 ? eventRecord.get('_uid') : crypto.randomUUID(),
                        name           : eventRecord.name,
                        startDate      : convertDateToSbFormat(`${eventRecord.startDate}`),
                        endDate        : convertDateToSbFormat(`${eventRecord.endDate}`),
                        component      : 'event',
                        resourceId     : eventRecord.resourceId,
                        readOnly       : eventRecord.readOnly || false,
                        draggable      : eventRecord.draggable !== false,
                        resizable      : eventRecord.resizable !== false,
                        allDay         : eventRecord.allDay || false,
                        exceptionDates : eventRecord.exceptionDates ? JSON.stringify(eventRecord.exceptionDates) : undefined,
                        recurrenceRule : eventRecord.recurrenceRule,
                        cls            : eventRecord.cls,
                        eventColor     : eventRecord.eventColor,
                        eventStyle     : eventRecord.eventStyle,
                        iconCls        : eventRecord.iconCls,
                        style          : eventRecord.style
                    };

                    if (item.events && existingEventIndex >= 0) {
                        // Update existing event
                        item.events[existingEventIndex] = eventData as Event;
                    }
                    else {
                        // Create new event (first update after add)
                        item.events?.push(eventData as Event);
                    }
                }
                return item;
            });

            const updatedStory = {
                story : {
                    ...storyDataState,
                    content : {
                        ...storyData.content,
                        body : updatedContent
                    }
                }
            };
            updateStory(updatedStory);
        }
    }

    if (storeId === 'resources') {
        const resourceRecord = record as CustomResourceModelType;

        if (action === 'remove') {
            const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
            const content = storyDataState.content;
            const updatedContent = content?.body?.map((item) => {
                if (item.component === 'calendar') {
                    records.forEach((res) => {
                        item.resources = item.resources?.filter(
                            (resource) => resource.id !== res.id
                        );
                    });
                }
                return item;
            });
            const updatedStory = {
                story : {
                    ...storyDataState,
                    content : {
                        ...storyData.content,
                        body : updatedContent
                    }
                }
            };
            updateStory(updatedStory);
        }

        if (action === 'update') {
            const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
            const content = storyDataState.content;
            const updatedContent = content?.body?.map((item) => {
                if (item.component === 'calendar') {
                    const existingResourceIndex = item.resources?.findIndex(
                        (resource) => resource.id === resourceRecord.id
                    );

                    const resourceData = {
                        id         : resourceRecord.id,
                        _uid       : resourceRecord.get('_uid') || crypto.randomUUID(),
                        name       : resourceRecord.name,
                        eventColor : resourceRecord.eventColor,
                        readOnly   : resourceRecord.readOnly || false,
                        component  : 'resource'
                    };

                    if (item.resources && existingResourceIndex && existingResourceIndex >= 0) {
                        // Update existing resource
                        item.resources[existingResourceIndex] = resourceData as Resource;
                    }
                    else {
                        // Create new resource (first update after add)
                        item.resources?.push(resourceData as Resource);
                    }
                }
                return item;
            });

            const updatedStory = {
                story : {
                    ...storyDataState,
                    content : {
                        ...storyData.content,
                        body : updatedContent
                    }
                }
            };
            updateStory(updatedStory);
        }
    }
};

We get the store, action, record, records, and changes data from the dataChange event parameters. The store is used to determine which data store has been changed. The action determines the type of data change, "remove" or "update".

We don’t handle the "add" action when an event is created. Instead, we create the event when it’s first updated. When an event is created in the Bryntum Calendar, an "add" event occurs and the event editor popup opens. When the new calendar event is saved, an "update" action is triggered and the event is created.

For each action, we update the storyData state object and pass the updated story object to the updateStory function. This function will call the update API.

Add the following updateStory function definition above the syncData function:

function updateStory(updatedStory: UpdatedStory) {
    delete updatedStory.story.content._editable;

    const currCalendarComponentIndex = updatedStory.story?.content?.body?.findIndex(
        (item) => item.hasOwnProperty('events')
    );

    if (!currCalendarComponentIndex) return;
    updatedStory.story.content.body?.map((item) => {
        delete item._editable;
    });
    if (updatedStory.story.content.body?.[currCalendarComponentIndex]?.events) {
        updatedStory.story.content.body[currCalendarComponentIndex].events =
        (updatedStory.story.content.body[currCalendarComponentIndex].events as CustomEventModelType[]).map((event: CustomEventModelType) => {
            delete event._editable;
            // Normalize exceptionDates to prevent Bryntum errors
            if (typeof event.exceptionDates === 'string' || event.exceptionDates === null) {
                event.exceptionDates = [];
            }
            return event;
        });
    }
    if (updatedStory?.story?.content?.body?.[currCalendarComponentIndex]?.resources) {
        updatedStory.story.content.body[currCalendarComponentIndex].resources =
        (updatedStory.story.content.body[currCalendarComponentIndex].resources as CustomResourceModelType[]).map((resource: CustomResourceModelType) => {
            delete resource._editable;
            return resource;
        });
    }

    // Optimistically update the state immediately to prevent stale data
    setStoryData(updatedStory.story);

    fetch('/api/update', {
        method  : 'PUT',
        headers : {
            'Content-Type' : 'application/json'
        },
        body : JSON.stringify(updatedStory)
    })
        .then((response) => response.json())
            .then(() => {
                // State already updated optimistically, just invalidate cache
                invalidateCache();
            })
            .catch((error) => {
                console.error('Error:', error);
                // Rollback on failure by refreshing from server
                refreshStory();
            });
}

If the update succeeds, the storyData state will be set to the new story data.

At the top of the file, add the following types for the syncData and updateStory functions:

type SyncData = ((event: {
    source: typeof ProjectConsumer;
    project: typeof ProjectModelMixin;
    store: Store;
    action: 'remove' | 'removeAll' | 'add' | 'clearchanges' | 'filter' | 'update' | 'dataset' | 'replace';
    record: Model | CustomEventModelType | CustomResourceModelType;
    records: Model[] | CustomEventModelType[] | CustomResourceModelType[];
    changes: object;
}) => void) | string

type UpdatedStory = {
    story: Story;
}

And the following imports:

import { Event, Resource } from '@/.storyblok/types/storyblok-components';
import { Model, ProjectConsumer, ProjectModelMixin, Store } from '@bryntum/calendar';
import { Story, StoryDataContext } from '../contexts/StoryData.context';
import { convertDateToSbFormat } from '../helpers';

Then, create a helpers.ts file in the app folder and add the following convertDateToSbFormat function:

export function convertDateToSbFormat(dateString: string): string {
    // Parse the input date string
    const date = new Date(dateString);

    // Extract year, month, day, hours, and minutes
    const year = date.getFullYear();
    const month = (date.getMonth() + 1).toString().padStart(2, '0');
    const day = date.getDate().toString().padStart(2, '0');
    const hours = date.getHours().toString().padStart(2, '0');
    const minutes = date.getMinutes().toString().padStart(2, '0');

    // Construct the output date string in the desired format
    const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}`;

    return formattedDate;
}

This function converts a JavaScript date to a date string properly formatted for Storyblok.

Now, when you update an event or resource in the Bryntum Calendar, the story data in Storyblok will be updated. If you make changes to the Bryntum Calendar in the Storyblok Visual Editor and click Save, you’ll see a Content Conflict dialog with the following message:

A newer version of this content item has been found in the database. Please choose how you want to proceed.

To save the changes you made from the Bryntum Calendar, select the Copy over option, and the newer version with your changes will open in a new window.

When working with draft content, you need to press the Save button in the Storyblok Visual Editor after making changes to the calendar. This is because calendar modifications create a new draft version that requires manual resolution of the content conflict.

Next steps

This tutorial gives you a starting point for using Bryntum Calendar with Storyblok. The Bryntum Calendar persists events, including recurring events, and resources.

With Storyblok and Bryntum Calendar working together, you’re ready to deliver modern, collaborative scheduling experiences for any use case. Keep experimenting, share your feedback, and let this integration be the launchpad for even more ambitious projects!

Take a look at the Bryntum Calendar examples page to see additional features you can add, such as:

Arsalan Khattak

Calendar