Skip to main content
Technology & EngineeringCms Services307 lines

Storyblok

Build with Storyblok as a visual headless CMS. Use this skill when the

Quick Summary26 lines
You are an expert in integrating Storyblok as a headless CMS. Storyblok is a
component-based headless CMS with a real-time visual editor that lets content
editors compose pages from reusable blocks while developers maintain full
control over rendering.

## Key Points

- Use `storyblokEditable(blok)` on every component's root element so the visual editor can highlight and click into it
- Resolve relations at query time with `resolve_relations` instead of making additional API calls
- Use the Storyblok image service transforms (`/m/WxH/filters:format(webp)`) instead of downloading and re-processing images
- Define a `catch-all` route (`[...slug]`) to let Storyblok's folder structure drive your URL hierarchy
- Keep bloks granular and composable — a `Hero` blok should accept a `buttons` field of nested `Button` bloks rather than hardcoding button fields
- Mixing up `draft` and `published` versions — always use `draft` only behind preview mode; public pages must use `published`
- Forgetting to register components in `storyblokInit` — unregistered bloks silently render nothing, which is hard to debug
- Not validating webhook signatures — always verify the `webhook-signature` header before revalidating
- Using the preview token in client-side code — the preview token exposes draft content; use the public token for client-side requests

## Quick Example

```env
STORYBLOK_API_TOKEN=your-preview-token
STORYBLOK_PUBLIC_TOKEN=your-public-token
STORYBLOK_WEBHOOK_SECRET=your-webhook-secret
```
skilldb get cms-services-skills/StoryblokFull skill: 307 lines
Paste into your CLAUDE.md or agent config

Storyblok — CMS Integration

You are an expert in integrating Storyblok as a headless CMS. Storyblok is a component-based headless CMS with a real-time visual editor that lets content editors compose pages from reusable blocks while developers maintain full control over rendering.

Core Philosophy

Overview

Storyblok treats every piece of content as a story composed of nestable bloks (components). Its visual editor overlays your actual frontend so editors see exactly what they are changing. Content is delivered via a REST or GraphQL Content Delivery API, with draft content available through a separate preview token.

Setup & Configuration

Install

# Next.js project
npm install @storyblok/react

# Nuxt project
npx nuxi module add @storyblok/nuxt

# Astro project
npx astro add @storyblok/astro

Environment variables

STORYBLOK_API_TOKEN=your-preview-token
STORYBLOK_PUBLIC_TOKEN=your-public-token
STORYBLOK_WEBHOOK_SECRET=your-webhook-secret

Client initialization (Next.js)

// lib/storyblok.ts
import { storyblokInit, apiPlugin } from '@storyblok/react/rsc';
import Page from '@/components/bloks/Page';
import Teaser from '@/components/bloks/Teaser';
import Grid from '@/components/bloks/Grid';
import Feature from '@/components/bloks/Feature';

export const getStoryblokApi = storyblokInit({
  accessToken: process.env.STORYBLOK_API_TOKEN,
  use: [apiPlugin],
  components: {
    page: Page,
    teaser: Teaser,
    grid: Grid,
    feature: Feature,
  },
});

Core Patterns

Content modeling with bloks

Bloks are Storyblok's reusable content components. Define them in the Storyblok UI, then create matching React components.

// components/bloks/Feature.tsx
import { storyblokEditable, type SbBlokData } from '@storyblok/react/rsc';

interface FeatureBlok extends SbBlokData {
  name: string;
  description: string;
  icon: { filename: string; alt: string };
}

export default function Feature({ blok }: { blok: FeatureBlok }) {
  return (
    <div {...storyblokEditable(blok)} className="feature">
      {blok.icon?.filename && (
        <img
          src={`${blok.icon.filename}/m/64x64`}
          alt={blok.icon.alt || ''}
        />
      )}
      <h3>{blok.name}</h3>
      <p>{blok.description}</p>
    </div>
  );
}

Fetching stories

// lib/storyblok.fetch.ts
import StoryblokClient from 'storyblok-js-client';

const client = new StoryblokClient({
  accessToken: process.env.STORYBLOK_API_TOKEN,
});

export async function getStory(slug: string, preview = false) {
  const { data } = await client.get(`cdn/stories/${slug}`, {
    version: preview ? 'draft' : 'published',
    resolve_relations: 'post.author,post.categories',
  });
  return data.story;
}

export async function getStories(params: {
  startsWith?: string;
  perPage?: number;
  page?: number;
}) {
  const { data, headers } = await client.get('cdn/stories', {
    version: 'published',
    starts_with: params.startsWith,
    per_page: params.perPage ?? 25,
    page: params.page ?? 1,
    is_startpage: false,
  });
  return {
    stories: data.stories,
    total: parseInt(headers.total, 10),
  };
}

Rendering nested bloks

// components/bloks/Page.tsx
import { StoryblokComponent, storyblokEditable, type SbBlokData } from '@storyblok/react/rsc';

interface PageBlok extends SbBlokData {
  body: SbBlokData[];
}

export default function Page({ blok }: { blok: PageBlok }) {
  return (
    <main {...storyblokEditable(blok)}>
      {blok.body?.map((nestedBlok) => (
        <StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
      ))}
    </main>
  );
}

Data fetching in Next.js App Router

// app/[...slug]/page.tsx
import { getStory } from '@/lib/storyblok.fetch';
import { StoryblokComponent } from '@storyblok/react/rsc';

export default async function CatchAllPage({
  params,
}: {
  params: { slug: string[] };
}) {
  const slug = params.slug?.join('/') || 'home';
  const story = await getStory(slug);

  return <StoryblokComponent blok={story.content} />;
}

export async function generateStaticParams() {
  const { stories } = await getStories({ perPage: 100 });
  return stories.map((story: any) => ({
    slug: story.full_slug.split('/'),
  }));
}

Visual editor bridge

// components/StoryblokBridgeLoader.tsx
'use client';

import { useStoryblokBridge } from '@storyblok/react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

export default function StoryblokBridgeLoader({ storyId }: { storyId: number }) {
  const router = useRouter();
  const [story, setStory] = useState<any>(null);

  useStoryblokBridge(storyId, (newStory) => {
    setStory(newStory);
    router.refresh();
  });

  return null;
}

Image optimization with Storyblok image service

// lib/storyblok.image.ts
export function optimizeImage(
  url: string,
  options: {
    width?: number;
    height?: number;
    quality?: number;
    format?: 'webp' | 'avif' | 'png' | 'jpeg';
    fit?: 'crop' | 'contain' | 'cover';
    focal?: string;
  } = {}
): string {
  if (!url) return '';
  const { width, height, quality = 80, format = 'webp', fit, focal } = options;
  const transforms: string[] = [];

  if (width || height) {
    transforms.push(`${width || 0}x${height || 0}`);
  }
  if (fit === 'crop' && focal) {
    transforms.push(`filters:focal(${focal})`);
  }
  if (quality) {
    transforms.push(`filters:quality(${quality})`);
  }
  if (format) {
    transforms.push(`filters:format(${format})`);
  }

  return `${url}/m/${transforms.join('/')}`;
}

Webhook-triggered revalidation

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import crypto from 'crypto';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('webhook-signature');

  const expectedSig = crypto
    .createHmac('sha1', process.env.STORYBLOK_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');

  if (signature !== expectedSig) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = JSON.parse(body);
  const slug = payload.full_slug || payload.story?.full_slug;

  if (slug) {
    revalidatePath(`/${slug}`);
  } else {
    revalidatePath('/', 'layout');
  }

  return Response.json({ revalidated: true, now: Date.now() });
}

Best Practices

  • Use storyblokEditable(blok) on every component's root element so the visual editor can highlight and click into it
  • Resolve relations at query time with resolve_relations instead of making additional API calls
  • Use the Storyblok image service transforms (/m/WxH/filters:format(webp)) instead of downloading and re-processing images
  • Define a catch-all route ([...slug]) to let Storyblok's folder structure drive your URL hierarchy
  • Keep bloks granular and composable — a Hero blok should accept a buttons field of nested Button bloks rather than hardcoding button fields

Common Pitfalls

  • Mixing up draft and published versions — always use draft only behind preview mode; public pages must use published
  • Forgetting to register components in storyblokInit — unregistered bloks silently render nothing, which is hard to debug
  • Not validating webhook signatures — always verify the webhook-signature header before revalidating
  • Using the preview token in client-side code — the preview token exposes draft content; use the public token for client-side requests

Anti-Patterns

Using the service without understanding its pricing model. Cloud services bill differently — per request, per GB, per seat. Deploying without modeling expected costs leads to surprise invoices.

Hardcoding configuration instead of using environment variables. API keys, endpoints, and feature flags change between environments. Hardcoded values break deployments and leak secrets.

Ignoring the service's rate limits and quotas. Every external API has throughput limits. Failing to implement backoff, queuing, or caching results in dropped requests under load.

Treating the service as always available. External services go down. Without circuit breakers, fallbacks, or graceful degradation, a third-party outage becomes your outage.

Coupling your architecture to a single provider's API. Building directly against provider-specific interfaces makes migration painful. Wrap external services in thin adapter layers.

Install this skill directly: skilldb add cms-services-skills

Get CLI access →