Skip to content

Conversation

@imranolas
Copy link
Contributor

@imranolas imranolas commented Sep 4, 2025

A reimagining of the Storyblok React SDK to focus purely on rendering and component registration, removing data fetching concerns and letting users handle their own data strategy.

Changes

  • Removed data fetching hooks (useStoryblokApi, useStoryblokState)
  • SDK now handles only component registration and content rendering through registration with a Bloks/Blok api (inspired by React Router)
  • Type safe throughout and integrates well with the types generated by the CLI
  • Works with any React setup both client and server side

Next.js 15 Demo

  • Recreated the Nuxt Storyblok demo site as a React reference implementation
  • Next.js 15 with App Router, SSR, visual editing, and SWR

Why is this better?

  • Rendering and data fetching are now separate concerns avoid leaky behaviour between SSR and client
  • Users can choose their preferred data fetching strategy. This example uses SWR but easily supports others
  • A stateless API that eliminates module/global caching

Reviewers notes

This PR is a bit of a monster but it broadly falls into the following pieces:

  1. React SDK. The changes here leave the API considerably smaller than previously. It's a much simpler API to use and maintain
  2. The bulk of the change comes from replacing and relocating the next15 playground app https://github.com/storyblok/monoblok/tree/spike/react-next/apps/next15-demo
  3. Along with the demo app I've also committed the generated types for the demo space which makes running and testing considerably easier.

resolves WDX-123

@pkg-pr-new
Copy link

pkg-pr-new bot commented Sep 4, 2025

Open in StackBlitz

@storyblok/astro

npm i https://pkg.pr.new/@storyblok/astro@287

storyblok

npm i https://pkg.pr.new/storyblok@287

@storyblok/eslint-config

npm i https://pkg.pr.new/@storyblok/eslint-config@287

@storyblok/js

npm i https://pkg.pr.new/@storyblok/js@287

storyblok-js-client

npm i https://pkg.pr.new/storyblok-js-client@287

@storyblok/management-api-client

npm i https://pkg.pr.new/@storyblok/management-api-client@287

@storyblok/nuxt

npm i https://pkg.pr.new/@storyblok/nuxt@287

@storyblok/react

npm i https://pkg.pr.new/@storyblok/react@287

@storyblok/region-helper

npm i https://pkg.pr.new/@storyblok/region-helper@287

@storyblok/richtext

npm i https://pkg.pr.new/@storyblok/richtext@287

@storyblok/svelte

npm i https://pkg.pr.new/@storyblok/svelte@287

@storyblok/vue

npm i https://pkg.pr.new/@storyblok/vue@287

commit: 10859c7

Comment on lines +78 to +115
<Bloks {...props} blok={blok} story={story} fallback={FallbackComponent}>
<Blok
component="featured-articles-section"
element={FeaturedArticlesSection}
/>
<Blok component="grid-section" element={GridSection} />
<Blok component="hero-section" element={HeroSection} />
<Blok component="image-text-section" element={ImageTextSection} />
<Blok component="tabbed-content-section" element={TabbedContentSection} />
<Blok component="text-section" element={TextSection} />
<Blok component="banner" element={Banner} />
<Blok component="banner-reference" element={BannerReference} />
<Blok
component="latest-articles-section"
element={LatestArticlesSection}
/>
<Blok component="faq-section" element={FaqSection} />
<Blok component="logo-section" element={LogoSection} />
<Blok
component="newsletter-form-section"
element={NewsletterFormSection}
/>
<Blok component="personalized-section" element={PersonalizedSection} />
<Blok component="products-section" element={ProductsSection} />
<Blok component="testimonials-section" element={TestimonialsSection} />
<Blok component="article-overview-page" element={ArticleOverviewPage} />
<Blok component="article-page" element={ArticlePage} />
<Blok component="category" element={Category} />
<Blok component="contact-form-section" element={ContactFormSection} />
<Blok component="price-card" element={PriceCard} />
<Blok component="grid-card" element={GridCard} />
<Blok component="image-card" element={ImageCard} />
<Blok component="richtext-youtube" element={RichtextYoutube} />
<Blok component="tabbed-content-entry" element={TabbedContentEntry} />
<Blok component="headline-segment" element={HeadlineSegment} />
<Blok component="default-page" element={DefaultPage} />
<Blok component="nav-item" element={NavItem} />
</Bloks>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This the crux of the API. It's basically a gloried switch statement. This is user generated code and users can create as many or as few of these as they choose to target different rendering contexts.

slug = slug.join("/");
}

const { story, header } = await fetchPage(slug);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async data fetching happens on the server and the result is populated in the SWR cache as fallback. This keeps the cache primed for the subsequent useSWR hook calls.

Comment on lines +26 to +27
[`cdn/stories/${slug}`]: story,
"cdn/stories/site-config": header,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache keys simply need to match the request params for useSWR. Similar patterns exist in other data fetching libraries

Comment on lines +5 to +11
const fetcher = (key: string) => getStory(key).then(res => res.data.story);

export const useStory = <T = unknown>(slug: string) => {
const { data, error, isLoading } = useSWR(`cdn/stories/${slug}`, fetcher);

return { story: data as ISbStoryData<T>, error, isLoading };
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user creates their own purpose built hook around their chosen data fetching library.

| StoryblokComponents.ArticlePage;

export function Story({ slug }: { slug: string }) {
const { story, error, isLoading } = useStory<Component>(slug);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a user hook that wraps the their data fetching library (SWR in this case) using the js client. Since the SWR cache is already pre-populated in the Server Component, this will simply return the data needed to complete SSR.

On the client, the SWR cache is re-hydrated and bridge updates will mutate the cache and trigger live updates.

Comment on lines +22 to +27
bridge.on("input", (event) => {
if (!event || !event.story) return;
mutate(`cdn/stories/${event.story.slug}`, event.story, {
revalidate: false,
});
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SWR cache is keyed by slug which is globally unique within the space. When a live update occurs we mutate the cache which triggers a re-render with the updated data. This occurs on the client only.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imranolas do you think is this something that could be part of the SDK?

Also, have you tried reusing the useStoryblokBridge exported from @storyblok/js? I'd suggest it for reusability as it performs a few extra checks:

  • Is in editor
  • Is the same story, by checking id (needed when you set multiple listeners in the same page
  • Already implements the change and published events

Adding the code suggestion below

@imranolas imranolas requested review from JDoza89 and edodusi September 4, 2025 14:44
Comment on lines +18 to +27
const bridge = new window.StoryblokBridge({
resolveRelations: resolveRelations,
});

bridge.on("input", (event) => {
if (!event || !event.story) return;
mutate(`cdn/stories/${event.story.slug}`, event.story, {
revalidate: false,
});
});
Copy link
Contributor

@alexjoverm alexjoverm Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const bridge = new window.StoryblokBridge({
resolveRelations: resolveRelations,
});
bridge.on("input", (event) => {
if (!event || !event.story) return;
mutate(`cdn/stories/${event.story.slug}`, event.story, {
revalidate: false,
});
});
const cb = event => mutate(`cdn/stories/${event.story.slug}`, event.story, { revalidate: false });
useStoryblokBridge(story.id, cb, { resolveRelations });

(would need BridgeHandler to have the story from [[...slug]]/page.tsx to be passed as property)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants