@buildinams/react-storyblok
TypeScript icon, indicating that this package has built-in type declarations

0.5.0 • Public • Published

react-storyblok

NPM version Actions Status PR Welcome

Opinionated Reactjs Storyblok wrapper that extends storyblok-js-client and @storyblok/js. It's built with the following pillars:

  • Fetching all data on the server
  • Full support for live preview + indicators
  • Abstracting the data to match your application on the server / preview mode
  • Minimal additional bundle size
  • Full Typescript support

Installation

Install this package with npm.

npm i @buildinams/react-storyblok

Concept

The logic is split up in two regions, server side only code and the React connection. This separation is also found in the package:

  • @buildinams/react-storyblok
  • @buildinams/react-storyblok/server

This is to make sure we don't do 'things' in the wrong place, for example we don't want to try to connect to the Storyblok bridge from the server or fetch data from the client. The only part of the code that's allowed to run in both instances is the Adaptor.

Adaptor

Data filled in the CMS is often not directly applicable to the components in your application. For example if we get a asset from the Storyblok it returns the asset width / height nested in the filename string field. So you may want to adapt this data to extract these values whenever you use the asset.

This is where the Adaptor comes in. It's a set of functions where all data fetched using this library gets piped through. Giving us this concept: 🪨 ➡️ ⚙️ ➡️ 🗿.

It supports the following types to adapt:

Creating a custom adaptor

To create a custom adaptor we recommend creating a new file called storyblokAdaptor.ts in your project. This file will contain all the adaptors for your project. For example:

import { StoryblokAdaptor } from "@buildinams/react-storyblok/server";

export const storyblokAdaptor = new StoryblokAdaptor({
  field_types: {
    asset: assetAdaptor,
  },

  plugins: {
    "native-color-picker": nativeColorPickerAdaptor,
  },

  content_types: {
    media: mediaAdaptor,
  },

  stories: {
    "*": wildcardStoryAdaptor,
    home: homeStoryAdaptor,
    "case/": caseRootStoryAdaptor,
    "case/[slug]": caseDetailPageStoryAdaptor,
  },

  tags: tagsAdaptor,

  datasources: datasourceAdaptor,
});

Couple things to note here:

  • The * is a wildcard that will match all stories. This is useful when you want to run a specific adaptor for all stories. This can only be used in stories.
  • The home is a specific adaptor that will match the story with the slug home. This is useful when you want to run an adaptor for a specific story.
  • The case/ is a specific adaptor that will match the root slug for the folder case/. This is useful when you want to run an adaptor on the root story of any folder.
  • The case/[slug] is a specific adaptor that will match the story with the slug case/[slug]. This is useful when you want to run an adaptor for dynamic stories.
  • You can only define a single unique adaptor for tags and datasources.

The Adaptors

An adaptor is a function that receives the data when it's matched and returns the adapted data. For example:

export const exampleAdaptor: Adaptor<any, any> = (data) => {
  // Do something with the data
  return adaptedData;
};

Note: You can also return null if needed. For example:

export const exampleAdaptor: Adaptor<any, any> = (data) => {
  return data.foo === "bar" ? null : data;
};

Updating Story Matcher

By default the story matcher will match the things noted above. If you want to change how stories match you can do so by updating the formatStoryPath handler on the adaptor. For example:

const storyPathHandler: StoryPathHandler = (data) => { ... };

export const storyblokAdaptor = new StoryblokAdaptor({
  stories: { ... },
  formatStoryPath: storyPathHandler,
});

Here, data is the story data that's being matched. The function expects a string to be returned and will be used to match all stories. For example, to simplify the matching we could do:

const storyPathHandler: StoryPathHandler = (data) => data.slug;

Fetcher

First step is getting data from Storyblok. This part is merely an opinionated wrapper around the Storyblok client from storyblok-js-client that adds some ease of use + runs your project specific Adaptor.

To create a fetcher we recommend creating a new file called storyblokFetcher.ts in your project. Then define you fetcher like this:

import { StoryblokFetcher } from "@buildinams/react-storyblok/server";

const ACCESS_TOKEN = process.env.STORYBLOK_TOKEN;

export const storyblokFetcher = new StoryblokFetcher({
  accessToken: ACCESS_TOKEN,
});

The fetcher takes the following arguments:

  • accessToken: Required - The Storyblok access token.
  • config: Optional - The Storyblok Client config.
  • adaptor: Optional - The adaptor to use for the data.
  • isPreview: Optional - Whether or not to use the preview API. Defaults to false.

Config API

The config supports the following options:

Property Type Required Notes
maxRetries number No Number of retries to make when a request fails. Defaults to 5.
storiesPerPage number No Number of stories to fetch per request. Defaults to 25.
suppressWarnings boolean No This can be used to suppress console.warn logs.
cache object No Custom cache config. Defaults to; clear - auto and type - memory.

Note: By default we don't cache requests. We do this to avoid stale Storyblok data. If you want to enable caching you can do so by overriding the default cache config with cache: { clear: 'manual' }. This will cache all requests until you clear the cache manually using params.cv prop on each fetcher function.

Available fetchers

Once you have defined the fetcher you can use it in your application. The follow fetchers are available:

  • getStory
  • getStories
  • getPagedStories
  • getDatasource
  • getTags

'getStory' API

This function will fetch a single story from Storyblok. It takes the following options:

  • slug: Required - The slug of the story to fetch.
  • params: Optional - The params to pass to the Storyblok API. See the d.ts type definitions for a full list of options.
  • isPreview: Optional - Whether or not to use the preview API. Defaults to false.

'getStories' API

This function will fetch a single page (by default up to 25 items) of stories from Storyblok. It takes the following options:

  • slug: Optional - The slugs of the stories to fetch.
  • params: Optional - The params to pass to the Storyblok API. See the d.ts type definitions for a full list of options.
  • isPreview: Optional - Whether or not to use the preview API. Defaults to false.

Note: Here page refers to how many items are returned per request. For example if you have 90 stories and params.per_page is set to 20 then you will need to fetch 5 pages of stories to get all the stories for a given call. This is handled automatically by the getPagedStories function. Or if you want to write custom paged logic you can get the total stories for a given request from response.headers. Read more about pagination here.

'getPagedStories' API

This function will fetch all stories from Storyblok. It works by recursively fetching all pages of stories until there are no more pages left. It takes the same arguments as getStories:

'getDatasource' API

This function will fetch a datasource_entries from Storyblok. It takes the following options:

  • slug: Required - The slug of the datasource to fetch.
  • params: Optional - The params to pass to the Storyblok API. See the d.ts type definitions for a full list of options.

'getTags' API

This function will fetch all tags from Storyblok. It doesn't take any additional options.

Renderer

Now that we have the option to fetch data plus clean it up for the application we're production ready. However Storyblok also offers two extra features:

  • Real time changes in the CMS
  • Indicators to match editable sections in preview mode

To handle these two points we need to insert minimal code into our renderer. This is also handled in two section to handle the two points.

'withStoryblokPreviewHOC'

First up is the connection to the bridge, this is an API that's only available in preview mode in the Storyblok CMS. It will provide real time updates while modifying data in the CMS. This is a higher order component that we recommend using as a wrapper, for example:

import { withStoryblokPreviewHOC } from "@buildinams/react-storyblok";
export const storyblokAdaptor = new StoryblokAdaptor({ ... });
const fetchStoryblokAdaptor = () => import("../path/to/adaptor");

export const withStoryblokPreview = (PageComponent) => {
  return withStoryblokPreviewHOC(PageComponent, fetchStoryblokAdaptor);
};

Notes:

  • We recommend using withStoryblokPreviewHOC inside a helper function. This is makes it easier to use the same adaptor across the site, without having to re-import it every time.
  • We use a dynamic import to only load the storyblokAdaptor in the client if the preview mode is active. This way in non-preview production mode we don't have to ship this logic to the client.
  • The withStoryblokPreviewHOC expects the dynamically imported file to have an export named: storyblokAdaptor.

Then you should use the withStoryblokPreview helper on every page with stories for preview support, for example in Next.js:

import { HomePage } from "~/scopes/HomePage";
import { storyblokFetcher } from "~/server/fetcher";

export const getStaticProps = async ({ preview = null }) => {
  const story = await storyblokFetcher.getStory({
    slug: "home",
    params: { resolve_relations: ["home.foo"] },
    isPreview: preview,
  });

  if (!story) {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      storyData: story,
      resolveRelations: ["home.foo"],
      preview,
    },
  };
};

export default withStoryblokPreview(HomePage);

Using this setup, withStoryblokPreview will automatically adapt the storyData prop on CMS changes.

Note: If you're resolving relations in your fetchers you need to pass the resolveRelations prop to the withStoryblokPreview function. This is to make sure the bridge knows which relations to adapt when receiving new data.

getBlocksRenderer

This is the final part of the puzzle and semi-optional. Now that we have data + real time updates the final hurdle is getting the green line indicators in the CMS preview. This is done by wrapping blocks that are rendered with the _editable identifier. However this is only needed when we are in preview mode. Outside of preview mode _editable is never set and the blocks will not be wrapped.

To make this process a bit less manual we have the helper function: getBlocksRenderer. This will create a React component that loops our items. It works as following:

import { getBlocksRenderer } from "@buildinams/react-storyblok";

const Renderer = getBlocksRenderer({
  fooBlock: FooBlock,
});

export const HomePage = ({ storyData }) => (
  <main>
    <Renderer blocks={storyData.blocks} title="Hello World 👋" />
  </main>
);

The renderer supports the following props:

  • blocks: Required - The list of blocks to render.
  • propsPerBlock: Optional - A function that will be called for every item. It will receive the item being rendered as the first argument and the index as the second argument. The return value of this function will be passed to the block as props that you can spread on the element.
  • ...rest: Optional - Any other props will be passed to the block.

Using previewIndicatorSpread

On initialising the Renderer we pass it an object containing a key / value lookup. Then when rendering the component we use this map to render the correct component. Whenever the list of blocks gets a value that isn't available in the lookup it will not render anything.

Under the hood the renderer uses the useStoryblokPreviewIndicatorSpread hook to provide all the props to make an HTML element show up as the editable element. We still need to attach this to whichever element you want to use in your block. This can be done like so:

const FooBlock = ({ previewIndicatorSpread, title }) => (
  <h1 {...previewIndicatorSpread}>{title}</h1>
);

Note: Make sure every component rendered by the getBlocksRenderer is provided the previewIndicatorSpread prop. Without this you won't see Storyblok block highlighting.

Using propsPerBlock

The getBlocksRenderer also provides a propsPerBlock prop. This is an object that contains the props that are passed to the component. This is useful when you want to pass additional props to a specific child component and not all. For example:

import { getBlocksRenderer } from "@buildinams/react-storyblok";

const BlocksRenderer = getBlocksRenderer({
  fooBlock: FooBlock,
  barBlock: BarBlock,
});

export const HomePage = ({ storyData }) => {
  return (
    <BlocksRenderer
      blocks={storyData.blocks}
      propsPerBlock={(item, index) => {
        if (item.component === "fooBlock") {
          return { isFoo: true };
        }

        if (index === 1) {
          return { isBar: true };
        }
      }}
    />
  );
};

Here you can see:

  • We pass the isFoo prop only to the FooBlock component via a item.component check.
  • We pass the isBar prop only to the BarBlock component via a index check.

Using The useStoryblokPreviewIndicatorSpread Hook

If you want to use the useStoryblokPreviewIndicatorSpread hook directly you can do so! This is useful if you want to use the spread on a custom component. For example:

import { useStoryblokPreviewIndicatorSpread } from "@buildinams/react-storyblok";

const FooBlock = ({ title, _editable }) => {
  const previewIndicatorSpread = useStoryblokPreviewIndicatorSpread(_editable);

  return <h1 {...previewIndicatorSpread}>{title}</h1>;
};

Now in the CMS you'll see the component highlighted when you hover over it + you can click it to open the content editor for that content type.

Requirements

This library requires a minimum React version of 17.0.0.

Requests and Contributing

Found an issue? Want a new feature? Get involved! Please contribute using our guideline here.

Package Sidebar

Install

npm i @buildinams/react-storyblok

Weekly Downloads

6

Version

0.5.0

License

MIT

Unpacked Size

112 kB

Total Files

59

Last publish

Collaborators

  • paulomfj
  • _brunotome_
  • buildinamsterdam