Storyblok is a headless CMS with a visual editor — storyblokInit({ accessToken, use: [apiPlugin] }) initializes the SDK. useStoryblok(slug, { version: "draft" }) fetches and updates a story in the browser with live editing via the Bridge. getStoryblokApi().get("cdn/stories/home", { version: "published" }) fetches server-side. StoryblokComponent renders a block by looking up its component name in a registry. Rich text renders with renderRichText(blok.body). storyblokEditable(blok) adds data-blok-c attributes for in-browser editing. Image CDN: story.content.image.filename + "/m/800x600" resizes on-the-fly. Nested blocks compose complex layouts from simple atoms. version: "draft" for preview; "published" for production. Next.js ISR uses next: { revalidate: 60 }. The Storyblok CLI generates TypeScript types from your space components. Claude Code generates Storyblok page renderers, component registries, Next.js ISR pages, and preview mode handlers.
CLAUDE.md for Storyblok
## Storyblok Stack
- Version: @storyblok/react >= 4.4, storyblok-js-client >= 6.8
- Init: storyblokInit({ accessToken: token, use: [apiPlugin], components: { hero: Hero, ... } })
- Server: const api = getStoryblokApi(); const { data } = await api.get("cdn/stories/home", { version: "published" })
- Client: const story = useStoryblok(slug, { version: "draft" }) — live editing
- Edit: {...storyblokEditable(blok)} spread on outermost element of each component
- Rich text: dangerouslySetInnerHTML={{ __html: renderRichText(blok.body) }}
- Image: blok.image.filename + "/m/1200x630/filters:quality(80):format(webp)"
- Types: npx storyblok-generate-ts — generates ComponentTypes from space
SDK Initialization
// lib/storyblok/init.ts — Storyblok client and components
import { storyblokInit, apiPlugin } from "@storyblok/react/rsc"
import { getStoryblokApi } from "@storyblok/react/rsc"
// Import all blok components
import HeroBlok from "@/components/storyblok/HeroBlok"
import FeatureListBlok from "@/components/storyblok/FeatureListBlok"
import TestimonialBlok from "@/components/storyblok/TestimonialBlok"
import PricingTableBlok from "@/components/storyblok/PricingTableBlok"
import RichTextBlok from "@/components/storyblok/RichTextBlok"
import ImageBlok from "@/components/storyblok/ImageBlok"
import PageBlok from "@/components/storyblok/PageBlok"
import GridBlok from "@/components/storyblok/GridBlok"
export const components = {
page: PageBlok,
hero: HeroBlok,
feature_list: FeatureListBlok,
testimonial: TestimonialBlok,
pricing_table: PricingTableBlok,
rich_text: RichTextBlok,
image: ImageBlok,
grid: GridBlok,
}
storyblokInit({
accessToken: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN!,
use: [apiPlugin],
components,
apiOptions: {
region: "", // "" for global, "us" for US data center
},
})
export { getStoryblokApi }
Content Fetching
// lib/storyblok/queries.ts — typed story queries
import { getStoryblokApi } from "./init"
import type { ISbStoryData } from "@storyblok/react/rsc"
export type StoryVersion = "published" | "draft"
// Fetch a single story by slug
export async function getStory<T = Record<string, unknown>>(
slug: string,
version: StoryVersion = "published",
): Promise<ISbStoryData<T> | null> {
const sbApi = getStoryblokApi()
try {
const { data } = await sbApi.get(`cdn/stories/${slug}`, {
version,
cv: version === "draft" ? Date.now() : undefined,
})
return data.story as ISbStoryData<T>
} catch (err: any) {
if (err?.response?.status === 404) return null
throw err
}
}
// Fetch multiple stories from a folder
export async function getStories<T = Record<string, unknown>>(params: {
startsWith?: string
contentType?: string
page?: number
perPage?: number
sortBy?: string
filterQuery?: Record<string, unknown>
version?: StoryVersion
}) {
const {
startsWith,
contentType,
page = 1,
perPage = 25,
sortBy = "published_at:desc",
filterQuery,
version = "published",
} = params
const sbApi = getStoryblokApi()
const { data, headers } = await sbApi.get("cdn/stories", {
starts_with: startsWith,
content_type: contentType,
page,
per_page: perPage,
sort_by: sortBy,
filter_query: filterQuery,
version,
})
return {
stories: data.stories as ISbStoryData<T>[],
total: parseInt(headers.total ?? "0"),
perPage,
}
}
// Get all slugs for static generation
export async function getAllSlugs(startsWith: string): Promise<string[]> {
const sbApi = getStoryblokApi()
const { data } = await sbApi.getAll("cdn/links", {
starts_with: startsWith,
})
return Object.values(data.links as Record<string, { slug: string }>)
.filter(link => !link.slug.endsWith("/"))
.map(link => link.slug)
}
// Storyblok image CDN transform
export function sbImage(
filename: string,
options: {
width?: number
height?: number
quality?: number
format?: "webp" | "avif" | "jpeg" | "png"
fit?: "fit-in" | "smart"
} = {},
): string {
if (!filename) return ""
const { width, height, quality = 80, format = "webp", fit } = options
const dimensions = width || height ? `${width ?? 0}x${height ?? 0}` : "0x0"
let path = `/m/${dimensions}`
if (fit) path += `/${fit}`
path += `/filters:quality(${quality}):format(${format})`
return filename + path
}
Blok Components
// components/storyblok/HeroBlok.tsx — typed hero blok
import { storyblokEditable, StoryblokComponent } from "@storyblok/react/rsc"
import { sbImage } from "@/lib/storyblok/queries"
type HeroBlokType = {
_uid: string
component: "hero"
headline: string
subheadline: string
cta_text: string
cta_url: string
background_image: {
filename: string
alt: string
}
body: StoryblokRichText
}
export default function HeroBlok({ blok }: { blok: HeroBlokType }) {
return (
<section
{...storyblokEditable(blok)}
className="relative min-h-[70vh] flex items-center justify-center overflow-hidden"
>
{blok.background_image?.filename && (
<img
src={sbImage(blok.background_image.filename, { width: 1920, height: 1080, format: "webp" })}
alt={blok.background_image.alt}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
<div className="absolute inset-0 bg-black/50" />
<div className="relative z-10 text-center text-white max-w-3xl px-6">
<h1 className="text-5xl font-bold tracking-tight mb-4">{blok.headline}</h1>
<p className="text-xl text-white/80 mb-8">{blok.subheadline}</p>
{blok.cta_text && (
<a
href={blok.cta_url}
className="inline-flex items-center px-8 py-3 bg-white text-black font-semibold rounded-full hover:bg-white/90 transition-colors"
>
{blok.cta_text}
</a>
)}
</div>
</section>
)
}
// components/storyblok/PageBlok.tsx — page container that renders blocks
import { StoryblokComponent, storyblokEditable } from "@storyblok/react/rsc"
type PageBlokType = {
_uid: string
component: "page"
body: Array<{ _uid: string; component: string; [key: string]: unknown }>
}
export default function PageBlok({ blok }: { blok: PageBlokType }) {
return (
<main {...storyblokEditable(blok)}>
{blok.body?.map(nestedBlok => (
<StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</main>
)
}
Next.js App Router Pages
// app/[...slug]/page.tsx — dynamic Storyblok pages with ISR
import { getStory, getAllSlugs } from "@/lib/storyblok/queries"
import { StoryblokStory } from "@storyblok/react/rsc"
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import "@/lib/storyblok/init" // Ensure components registered
type Params = { slug: string[] }
export async function generateStaticParams() {
const slugs = await getAllSlugs("")
return slugs.map(slug => ({ slug: slug.split("/").filter(Boolean) }))
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const slug = params.slug?.join("/") ?? "home"
const story = await getStory(slug)
if (!story) return {}
return {
title: story.content?.seo_title ?? story.name,
description: story.content?.seo_description ?? undefined,
openGraph: story.content?.og_image?.filename ? {
images: [{ url: story.content.og_image.filename + "/m/1200x630" }],
} : undefined,
}
}
export default async function CatchAll({ params }: { params: Params }) {
const slug = params.slug?.join("/") ?? "home"
// Check if preview mode via cookie
const isPreview = false // Check cookies().get("preview") in real app
const story = await getStory(slug, isPreview ? "draft" : "published")
if (!story) notFound()
return <StoryblokStory story={story} />
}
export const revalidate = 60 // ISR: re-generate every 60 seconds
For the Contentful alternative when a more enterprise-grade headless CMS with GraphQL API, rich asset management, and larger team collaboration features is needed — Contentful has been the enterprise standard longer than Storyblok, though Storyblok’s visual editor makes content editing less technical for non-developers, see the Contentful guide. For the Payload CMS alternative when hosting the CMS in your own Next.js codebase with TypeScript-native collections, access control, and no separate service is preferred — Payload runs in your repo rather than as a separate managed service, see the Payload CMS guide. The Claude Skills 360 bundle includes Storyblok skill sets covering SDK integration, blok components, and ISR. Start with the free tier to try visual CMS generation.