@buildinams/react-storyblok
v0.5.1
Published
Opinionated Reactjs Storyblok wrapper
Downloads
42
Readme
react-storyblok
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 instories
. - The
home
is a specific adaptor that will match the story with the slughome
. 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 foldercase/
. 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 slugcase/[slug]
. This is useful when you want to run an adaptor for dynamic stories. - You can only define a single unique adaptor for
tags
anddatasources
.
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 tofalse
.
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 thed.ts
type definitions for a full list of options.isPreview
: Optional - Whether or not to use the preview API. Defaults tofalse
.
'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 thed.ts
type definitions for a full list of options.isPreview
: Optional - Whether or not to use the preview API. Defaults tofalse
.
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 thed.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 theFooBlock
component via aitem.component
check. - We pass the
isBar
prop only to theBarBlock
component via aindex
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.