Claude Code for Storyblok: Visual CMS with React Components — Claude Skills 360 Blog
Blog / Backend / Claude Code for Storyblok: Visual CMS with React Components
Backend

Claude Code for Storyblok: Visual CMS with React Components

Published: May 19, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free