Skip to content

@storyblok/astro (Version 11.x)

@storyblok/astro is Storyblok’s official SDK for Astro applications.

Version 11 separates rendering from data fetching. Fetch content with your own client, such as @storyblok/api-client, and render it with the StoryblokBlock component returned by defineStoryblokBlocks. To upgrade from version 8.x-10.x, follow the migration guide.

  • Astro version 5.0 or later
  • Node.js LTS (version 22.x recommended)
  • Modern web browser (for example, Chrome, Firefox, Safari, Edge—latest versions)

Add the package to a project by running this command in the terminal:

Terminal window
npm install @storyblok/astro@latest @storyblok/api-client@latest

Set up the API client and the block registry, create the block components, and render a story.

Create an @storyblok/api-client instance with the space’s access token, and register the Astro components used to render your Storyblok blocks.

src/storyblok.ts
import { defineStoryblokBlocks } from "@storyblok/astro";
import { createApiClient } from "@storyblok/api-client";
import Page from "./components/Page.astro";
import Feature from "./components/Feature.astro";
export const apiClient = createApiClient({
accessToken: "YOUR_ACCESS_TOKEN",
region: "eu",
});
export const { StoryblokBlock, StoryblokRichText } = defineStoryblokBlocks(() => ({
components: {
page: Page,
feature: Feature,
},
}));

Learn how to retrieve an access token in the access tokens concept.

Create an Astro component for each block defined in Storyblok and registered in the configuration. Each component receives a block prop that contains the block’s content, and an editable prop.

src/components/Feature.astro
---
const { block, editable } = Astro.props;
---
<div {...editable}>
<h2>{block.headline}</h2>
</div>

Register only Astro (.astro) components. To render a block with a framework component, such as React or Vue, wrap it in an Astro component and apply the client:* directive there.

Use <StoryblokBlock> to render nested blocks, provided they’re registered in the same defineStoryblokBlocks registry.

src/components/Page.astro
---
import type { StoryblokBlockData } from "@storyblok/astro";
import { StoryblokBlock } from "../storyblok";
const { block, editable } = Astro.props;
---
<main {...editable}>{block.body?.map((child: StoryblokBlockData) => <StoryblokBlock block={child} />)}</main>

In an Astro page or component, use the API client to fetch a story and render the content using StoryblokBlock.

src/pages/index.astro
---
import { apiClient, StoryblokBlock } from "../storyblok";
import BaseLayout from "../layouts/BaseLayout.astro";
const { data } = await apiClient.stories.get("home", {
query: { version: "draft" },
});
const story = data?.story;
if (!story) {
return new Response(null, { status: 404 });
}
---
<BaseLayout>
<StoryblokBlock block={story.content} />
</BaseLayout>

To enable live editing in the Visual Editor, render StoryblokPreview alongside the content.

@storyblok/astro exports the following components, middleware, and helpers.

Registers the block → component map and returns the StoryblokBlock and StoryblokRichText components bound to it.

interface ExtraProps {
locale?: string;
}
export const { StoryblokBlock, StoryblokRichText } = defineStoryblokBlocks<ExtraProps>(() => ({
components: {
page: Page,
feature: Feature,
"featured-articles": FeaturedArticles,
},
fallback: FallbackBlock,
}));

The factory’s return value accepts the following options:

Key Description Type
components Map of Storyblok technical block names to the Astro components that render them. StoryblokComponentMap
fallback Rendered for block types with no matching entry in components. Receives the same props as a registered component. StoryblokBlockComponent
warnings Logs a warning when StoryblokBlock renders nothing: for an undefined block, an unregistered block type without fallback, or a component that isn’t an Astro component. Defaults to true. boolean

Each warning prints once per registry. Without a fallback, an unregistered block type renders nothing.

The optional <ExtraProps> generic declares extra props forwarded to every registered component and onto StoryblokBlock itself (locale above). A required field makes every StoryblokBlock usage, including recursive ones, supply it; an optional field stays optional.

Each defineStoryblokBlocks call creates an independent registry. To use separate component maps, for example one per content area, call it more than once.

Use the StoryblokBlockComponentProps type to type the props of a block component. The first type argument describes the block’s fields, and the second describes the extra props.

src/components/Feature.astro
---
import type { StoryblokBlockComponentProps } from "@storyblok/astro";
type Props = StoryblokBlockComponentProps<{ headline: string }, { locale?: string }>;
const { block, editable, locale } = Astro.props;
---
<div {...editable} lang={locale}>
<h2>{block.headline}</h2>
</div>

Astro middleware required for live editing. It stores the Visual Editor’s preview payload in Astro.locals, so getPayload can read it back. Without it, getPayload always returns an empty object. When the liveUpdate morph re-requests the page via POST, the page then falls back to stale, non-preview content instead of the edited story. The middleware only captures the payload for same-origin JSON POST requests that include the Visual Editor’s query parameters, so it stays empty outside the Visual Editor.

src/middleware.ts
import { sequence } from "astro:middleware";
import { storyblokPreviewMiddleware } from "@storyblok/astro";
export const onRequest = sequence(storyblokPreviewMiddleware);

getPayload provides access to live preview data, including the story and any custom server-side data. It requires storyblokPreviewMiddleware.

Use it to:

  • Access live preview story data
  • Pass and reuse server-side data during preview
  • Avoid fetching external resources again
---
import { getPayload, StoryblokPreview, StoryblokServerData, type StoryblokBlockData } from "@storyblok/astro";
import { apiClient, StoryblokBlock } from "../storyblok";
import { getUsers, type User } from "../lib/getUsers";
interface ServerData {
users?: User[];
}
interface MyStory {
content: StoryblokBlockData<{ myField: string }>;
}
const { slug } = Astro.params;
const payload = await getPayload<ServerData, MyStory>({
locals: Astro.locals,
});
// Use live preview data or fall back to the API client
const story =
payload.story ??
(
await apiClient.stories.get(slug || "home", {
query: { version: "draft", resolve_relations: "featured-articles.articles" },
})
).data?.story;
const users = payload.serverData?.users ?? (await getUsers());
if (!story) {
return new Response(null, { status: 404 });
}
---
<p>{users?.length} users loaded.</p>
<StoryblokBlock block={story.content} />
<StoryblokServerData users={users} />
<StoryblokPreview liveUpdate bridgeOptions={{ resolveRelations: ["featured-articles.articles"] }} />

The browser sends serverData back with every live preview update, so any client can modify it. Use it only for display data, never for IDs, permissions, or other values the server relies on.

Pass the same relations to resolve_relations and to the resolveRelations option of bridgeOptions, so the live preview resolves them too. To replace the references in the API response with the resolved stories, set inlineRelations: true in createApiClient. Learn more about inline relations.

StoryblokServerData provides pre-fetched server-side data to the live preview environment.

Providing server-side data this way is useful when an application depends on external data sources (for example, APIs, databases) that are slow or expensive to fetch on every update. StoryblokServerData sends the data once, and live preview updates reuse it.

<StoryblokServerData users={users} />

The component returned by defineStoryblokBlocks. It renders a single block, resolving its component from that call’s registry, and forwards any additional props to the resolved component. When block has no value, for example story?.content after a failed fetch, StoryblokBlock renders nothing and logs a warning outside the Visual Editor.

<StoryblokBlock block={story.content} />

Use it to iterate over a blocks field, such as body:

{block.body?.map((child: StoryblokBlockData) => <StoryblokBlock block={child} />)}

Connects the page to the Visual Editor: it always reloads the page when you save or publish in the Visual Editor, and (with liveUpdate) also morphs the DOM as you type. Learn more about its behavior in Live preview.

<StoryblokPreview liveUpdate debounceMs={200} bridgeOptions={{ resolveRelations: ["featured-articles.articles"] }} />
Key Description Type
liveUpdate Also morphs the DOM as you type, instead of waiting for save or publish. Defaults to false. boolean
debounceMs Delay in milliseconds after the last input event before the page updates. Defaults to 500. number
bridgeOptions Options forwarded to the Preview Bridge constructor. BridgeParams

liveUpdate sends each update to the current URL as a POST request and renders the response, so the route must render on demand. On a route that Astro renders at build time, the update request fails, and StoryblokPreview logs a warning in development. Reloading on save or publish works on every route.

Render exactly one <StoryblokPreview /> per page, for example in the layout. A second instance throws an error in development, and logs a warning in production while only the first instance stays active. StoryblokPreview also works with Astro’s <ClientRouter />.

Conditionally render the StoryblokPreview component based on the route or other criteria. There’s no route-level opt-out flag: not rendering the component is the opt-out.

{!disableLivePreview && <StoryblokPreview liveUpdate />}

Every component rendered through StoryblokBlock receives an editable prop. Spread editable onto the root element of the component to make it editable in the Visual Editor.

---
const { block, editable } = Astro.props;
---
<section {...editable}>{block.title}</section>

For components that aren’t rendered through StoryblokBlock, use storyblokEditable() instead. It accepts a block from the Storyblok API and returns an object containing the same HTML attributes.

---
import { storyblokEditable } from "@storyblok/astro";
const { block } = Astro.props;
---
<section {...storyblokEditable(block)}>{block.title}</section>

Checks whether a given URL belongs to a request made from inside the Visual Editor.

import { isInEditor } from "@storyblok/astro";
const inEditor = isInEditor(Astro.url);

Used to render a rich text field from a Storyblok story. @storyblok/astro provides two variants of the component:

  • The StoryblokRichText returned by defineStoryblokBlocks renders embedded blocks through the same registry as StoryblokBlock.
  • The StoryblokRichText exported by @storyblok/astro has no registry, and renders embedded blocks only through components.blok.

Use the registry-bound variant for rich text fields that can contain blocks:

---
import { StoryblokRichText } from "../storyblok";
const { block } = Astro.props;
---
<StoryblokRichText document={block.richtext_field} />

Pass a plain object to data to forward it to every embedded block as extra props. TypeScript doesn’t compare data with the registry’s <ExtraProps>. To check the type, use satisfies:

<StoryblokRichText document={block.richtext_field} data={{ locale: "es" } satisfies ExtraProps} />

The StoryblokRichText exported by @storyblok/astro skips embedded blocks and logs a warning, unless components.blok provides a renderer:

---
import { StoryblokRichText } from "@storyblok/astro";
import EmbeddedBlok from "../components/EmbeddedBlok.astro";
---
<StoryblokRichText document={block.richtext_field} components={{ blok: EmbeddedBlok }} />

A components.blok renderer also replaces the registry in the registry-bound variant.

Both variants accept the following optional props:

  • components: Provides custom Astro components for supported rich text nodes and marks.
  • optimizeImage: Configures image optimization for image nodes.
  • data: Shares custom data with all custom rich text components. Each component receives the data as context.data. In the registry-bound variant, embedded blocks also receive it as extra props.
  • warnings: Set to false to silence the warnings for embedded blocks that render nothing. Defaults to true.

For a complete list of supported nodes, marks, and customization options, refer to the @storyblok/richtext reference.

Register custom components with the components prop. The key must match a supported rich text node or mark name.

import type { StoryblokAstroRichTextComponentMap } from "@storyblok/astro";
const components: StoryblokAstroRichTextComponentMap = {
heading: CustomHeading,
link: CustomLink,
table: CustomTable,
};

Then, pass the components to StoryblokRichText:

<StoryblokRichText document={block.richtext_field} components={components} />

Marks receive their rendered child content through Astro’s <slot />.

---
import type { StoryblokAstroRichTextProps } from "@storyblok/astro";
type Props = StoryblokAstroRichTextProps<"link">;
const { attrs } = Astro.props;
---
<a href={attrs?.href ?? ""} target={attrs?.target ?? "_self"}>
<slot />
</a>

Nodes can use Astro’s <slot /> to pass through child content that StoryblokRichText already rendered.

---
import type { StoryblokAstroRichTextProps } from "@storyblok/astro";
import type { HTMLTag } from "astro/types";
type Props = StoryblokAstroRichTextProps<"heading">;
const { attrs } = Astro.props;
const Tag = `h${attrs?.level ?? 1}` as HTMLTag;
---
<Tag class="custom-heading">
<slot />
</Tag>

Example: custom table component (advanced node)

Section titled “Example: custom table component (advanced node)”

For advanced use cases, nodes can access content and context to recursively render child nodes using StoryblokRichText.

To render semantic table nodes, use the splitTableRows utility from @storyblok/richtext and create <thead> and <tbody> elements.

---
import { StoryblokRichText, type StoryblokAstroRichTextProps } from "@storyblok/astro";
import { splitTableRows } from "@storyblok/richtext";
type Props = StoryblokAstroRichTextProps<"table">;
const { content, context } = Astro.props;
const { headerRows, bodyRows } = splitTableRows(content);
---
<table class="custom-table">
{
headerRows.length > 0 && (
<thead>
<StoryblokRichText document={headerRows} {...context} />
</thead>
)
}
<tbody>
<StoryblokRichText document={bodyRows} {...context} />
</tbody>
</table>

StoryblokPreview with liveUpdate re-renders the page on the server as you type, and morphs the result into the current DOM.

Use these events to hook into the live preview rendering lifecycle:

  • storyblok-live-preview-updating: Fires when a live preview update starts, before the DOM updates.
  • storyblok-live-preview-updated: Fires after the DOM updates.

Both events dispatch on document, require <StoryblokPreview liveUpdate />, and contain the edited story in event.detail.story.

<script>
document.addEventListener("storyblok-live-preview-updating", (event) => {
console.log("Storyblok live preview: DOM updating", event.detail.story);
});
document.addEventListener("storyblok-live-preview-updated", (event) => {
console.log("Storyblok live preview: DOM updated", event.detail.story);
});
</script>

If the Visual Editor has a focused element, live preview morphs only that element and preserves its interactive state (value, checked, …). To keep other parts of the UI during live preview updates, regardless of focus, add the data-preserve-state attribute.

<div data-preserve-state></div>

The storyblok-live-preview-updating event is cancelable. Call event.preventDefault() to skip an update:

document.addEventListener("storyblok-live-preview-updating", (event) => {
event.preventDefault();
});

Was this page helpful?

What went wrong?

This site uses reCAPTCHA and Google's Privacy Policy (opens in a new window).Terms of Service (opens in a new window) apply.