Storyblok Tool Plugins are useful when editors need to perform an action directly in the Visual Editor.
In this article, we will build a Tool Plugin with SvelteKit. The plugin reads the current story context, checks space-level settings, and sends the story payload to a configured webhook.
The example uses SvelteKit, but the architecture works in the same way with Next.js, Nuxt, Remix, Express, or any framework that can expose server routes.
What is a Storyblok Tool Plugin?
Storyblok plugins let developers extend Storyblok with custom features. They can improve the editing experience, integrate third-party systems, or add tools tailored to a team's workflow.
Storyblok provides different plugin types. Field Plugins extend the editing form with custom inputs. Space Plugins can add functionality at the space level. Tool Plugins add custom tool windows to the Visual Editor.
A Tool Plugin is useful when the editor needs to run an action while looking at a specific story. It appears in the Visual Editor, communicates with Storyblok via the App Bridge, and requests the current story context via postMessage.
That context is the important part. The plugin can determine which story the editor is working on and use that information to trigger an external process, call an internal API, validate content, start a translation flow, export data, or send content to another system.
For developers, this is worth knowing because it gives you a controlled way to bring project-specific workflows into the CMS. Instead of asking editors to copy IDs, open external dashboards, or remember manual steps, you can expose a small action right where the editorial decision already happens.
The technical model is also familiar. A Tool Plugin is a web application embedded in Storyblok. It has a browser UI, server routes, environment variables, OAuth when needed, and API calls. If you know how to build a small app with SvelteKit, Next.js, Nuxt, or Express, you already understand most of the building blocks.
The use case
The plugin we are going to build with this article is called Send to Flowmotion.
Storyblok webhooks are a good fit for automatic integrations. They can trigger external systems when something specific happens, such as a workflow stage change, an image upload, or a content publication. That model works well when the process should always run after an event.
Some workflows need a manual decision. An editor may want to trigger a Flowmotion workflow only for a specific story, at a specific moment, and for a specific purpose. Examples include publishing a selected article to LinkedIn, sending a message to another team, exporting content to a third-party system, or starting a custom review process.
That is the reason for this Tool Plugin. The editor opens a story in the Storyblok Visual Editor, clicks a tool in the sidebar, and sends the current story payload to an external Flowmotion webhook.
The payload is important. Because the story data is already sent to Flowmotion, the workflow often does not need extra nodes only to retrieve the content, resolve the story, or look up the selected item again.
The plugin has to handle a few things:
- Run inside the Storyblok Visual Editor iframe.
- Request the current story context through App Bridge.
- Authenticate with Storyblok OAuth.
- Read plugin settings from the Storyblok Management API.
- Keep the webhook URL server-side.
- Forward the story payload from a backend route.
- Show clear loading, setup, auth, success, and error states.
This is a small feature, but it touches the main parts of a real Tool Plugin.
The architecture
The implementation has three layers:
- The iframe UI.
- The app backend.
- Storyblok and the external webhook.
The browser-side plugin should only handle editor interaction. It can request context from Storyblok, show states, and call your backend. It should not expose OAuth tokens, client secrets, or private webhook URLs.
The backend does the sensitive work:
- Starts and handles Storyblok OAuth.
- Reads the authenticated Storyblok session.
- Calls the Management API.
- Resolves the configured webhook URL.
- Sends the story payload to the webhook.
The flow looks like this:
Storyblok Visual Editor
|
| App Bridge postMessage
v
Tool Plugin iframe
|
| /api/config
v
SvelteKit backend
|
| Storyblok Management API
v
Space-level plugin settings
|
| /api/trigger-webhook
v
Flowmotion webhook
This separation matters. A Tool Plugin often runs in a browser context controlled by an editor session, while the backend has access to private credentials. Keep those boundaries clear from the beginning.
Step 1: Start with HTTPS locally
Storyblok loads Tool Plugins in an iframe. For local development, the app must be available over HTTPS.
In this project, Vite uses local certificate files when they exist:
.cert/localhost-key.pem
.cert/localhost-cert.pem
You can create them with mkcert:
mkcert -install
mkdir -p .cert
mkcert -key-file .cert/localhost-key.pem -cert-file .cert/localhost-cert.pem localhost 127.0.0.1 ::1
Then start the app:
bun run dev
The local app is available at:
https://localhost:5173
This is not only a development detail. If the iframe cannot load correctly, the rest of the plugin cannot be tested inside the Visual Editor.
Step 2: Create a small plugin shell first
Before adding App Bridge or OAuth, build the first screen.
The plugin shell should include:
- A title.
- A loading state.
- An empty or waiting state.
- A configuration status area.
- A primary action button.
- Secondary story metadata.
- A compact layout suitable for the Visual Editor tools panel.
This avoids mixing UI questions with integration questions. When the UI shell is stable, you can add App Bridge and backend calls step by step.
In this project, the main UI lives in:
src/routes/+page.svelte
The send button is only enabled when three conditions are true:
let canSend = $derived(
Boolean(context && configStatus === 'loaded' && config?.authenticated && config.configured)
);
The same idea applies in React, Vue, or any other framework. Compute the action state from the current context and backend configuration. Do not let the editor click an action until the plugin knows it can complete it.
For the final layout, keep the editor action high in the panel. In this example, the UI shows the story name first, then the configuration status, then the Send to Flowmotion button. Diagnostic metadata such as slug, story ID, space ID, and language appears below the action.
Step 3: Request Storyblok context with App Bridge
The Tool Plugin receives story data from Storyblok through postMessage.
The plugin sends a message to the parent frame:
function postToStoryblok(event: string, data: Record<string, unknown> = {}) {
const message = {
action: 'tool-changed',
tool: PLUGIN_SLUG,
event,
...data
};
window.parent.postMessage(message, '*');
}
When the component mounts, it resizes the iframe and requests context:
onMount(() => {
window.addEventListener('message', handleMessage);
postToStoryblok('heightChange', { height: IFRAME_HEIGHT });
postToStoryblok('getContext');
return () => {
window.removeEventListener('message', handleMessage);
};
});
The plugin then normalizes incoming messages. This is useful because payloads can be shaped differently depending on the source or wrapper:
function handleMessage(event: MessageEvent) {
const message = normalizeContextMessage(event.data);
if (!message) return;
if (
(message.action === 'get-context' || message.action === 'loaded' || message.story) &&
message.story
) {
setContext({
story: message.story,
spaceId: message.spaceId,
language: message.language
});
}
}
After the story context is available, the UI can show the story name and ask the backend whether the plugin is configured.
Step 4: Add a local mock mode
App Bridge only works when the plugin runs inside Storyblok. That can slow down local iteration.
A mock mode helps you test the UI outside the Visual Editor:
const ENABLE_MOCK_CONTEXT = env.PUBLIC_ENABLE_MOCK_CONTEXT === 'true';
const mockContext = {
story: {
id: 123456789,
uuid: 'mock-story-uuid',
name: 'Article created from local mock context',
full_slug: 'articles/article-created-from-local-mock-context',
content: {}
},
spaceId: '123456',
language: 'default'
};
This is useful for screenshots, local UI work, and debugging. Keep it disabled in production:
PUBLIC_ENABLE_MOCK_CONTEXT=false
The principle is framework-independent. Give yourself a local fallback, but make sure it cannot mask real production behavior.
Step 5: Define the backend contract early
Before calling the Storyblok Management API, create the backend routes and return mock responses.
This project uses two routes:
GET /api/config
POST /api/trigger-webhook
GET /api/config answers this question:
Can the current user and space send this story to Flowmotion?
A configured response can look like this:
{
"authenticated": true,
"configured": true,
"settings": {
"hasWebhookUrl": true,
"httpMethod": "POST"
}
}
An auth-required response can look like this:
{
"authenticated": false,
"configured": false,
"connectUrl": "https://localhost:5173/api/connect/storyblok",
"missing": ["Connect Storyblok before reading space-level settings."]
}
This contract lets the frontend handle states before the real integration exists. It also makes the implementation easier to port to other frameworks. In Next.js this would be an API route. In Nuxt it would be a server route. In Express it would be a normal endpoint.
Step 6: Add OAuth with the official helper
The plugin needs OAuth because the backend reads space-level plugin settings through the Storyblok Management API.
The project uses Storyblok's official helper:
bun add @storyblok/app-extension-auth
The required environment variables are:
APP_CLIENT_ID=your-storyblok-app-client-id
APP_CLIENT_SECRET=your-storyblok-app-client-secret
APP_URL=https://localhost:5173
The auth helper is wrapped in a small server module:
export function getAuthParams(): AuthHandlerParams {
const clientId = env.APP_CLIENT_ID;
const clientSecret = env.APP_CLIENT_SECRET;
const baseUrl = env.APP_URL;
if (!clientId || !clientSecret || !baseUrl) {
throw new Error('Missing APP_CLIENT_ID, APP_CLIENT_SECRET, or APP_URL.');
}
return {
clientId,
clientSecret,
baseUrl,
endpointPrefix: '/api/connect',
sessionKey: 'send-to-flowmotion.sb.auth',
successCallback: '/',
errorCallback: '/401'
};
}
The SvelteKit route delegates OAuth handling to the helper:
export const GET: RequestHandler = handleAuth;
export const POST: RequestHandler = handleAuth;
The editor starts OAuth through:
/api/connect/storyblok
The callback URL is:
/api/connect/callback
For production, register the full HTTPS callback URL in Storyblok:
https://your-plugin.example.com/api/connect/callback
Step 7: Read space-level settings
The webhook URL should not be hardcoded in the frontend. It should come from Storyblok space-level settings and be read on the server.
In Storyblok, configure the installed Tool Plugin with:
webhook_url=https://your-flowmotion-webhook.example.com/...
http_method=POST
The backend uses the OAuth session to call the Management API:
const response = await fetch(
`${getManagementBaseUrl(session.region)}/v1/spaces/${session.spaceId}/app_provisions/`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
Accept: 'application/json'
}
}
);
The plugin then finds the installed app provision by slug and reads space_level_settings:
const settings = readSpaceLevelSettings(provision.space_level_settings);
const webhookUrl = readWebhookUrlSetting(settings.webhook_url);
const httpMethod = readStringSetting(settings.http_method)?.toUpperCase();
The code validates both settings:
function getMissingSettings(config: Pick<FlowmotionConfig, 'httpMethod' | 'webhookUrl'>) {
const missing: string[] = [];
if (!config.webhookUrl) {
missing.push('Add a valid absolute webhook_url setting.');
}
if (!config.httpMethod) {
missing.push('Add the http_method setting.');
} else if (!ALLOWED_HTTP_METHODS.has(config.httpMethod)) {
missing.push('Set http_method to POST.');
}
return missing;
}
This is a good pattern for Tool Plugins. Let Storyblok store space-specific configuration, then resolve it server-side when the editor performs an action.
Step 8: Forward the story payload from the backend
The frontend calls the backend with the current story context:
const response = await fetch('/api/trigger-webhook', {
method: 'POST',
headers: {
'content-type': 'application/json',
'X-Storyblok-Space-Id': String(context.spaceId ?? ''),
'X-Storyblok-User-Id': getUserId() ?? ''
},
body: JSON.stringify({
story: context.story,
spaceId: context.spaceId,
language: context.language ?? 'default'
})
});
The server validates the request:
function validateTriggerPayload(payload: TriggerWebhookRequest) {
if (!isRecord(payload.story)) {
return 'A story payload is required.';
}
if (typeof payload.spaceId !== 'string' && typeof payload.spaceId !== 'number') {
return 'A Storyblok space ID is required.';
}
if (payload.language !== undefined && typeof payload.language !== 'string') {
return 'Language must be a string.';
}
}
Then it loads the Storyblok session, resolves the Flowmotion config, and forwards the payload:
const forwardedPayload = {
plugin: getPluginSlug(),
story: payload.story,
spaceId: payload.spaceId,
language: payload.language ?? 'default',
triggeredAt: new Date().toISOString()
};
const response = await fetch(config.webhookUrl, {
method: config.httpMethod,
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(forwardedPayload)
});
This keeps the webhook URL out of the browser. It also gives you one place to handle retries, logging, payload validation, rate limits, or future transformations.
Step 9: Return useful error messages
When the webhook call fails, a generic 502 is not enough.
The backend should return a useful but safe message:
if (!response.ok) {
const responseBody = await readResponseBodySnippet(response);
const upstreamMessage = [
`Flowmotion webhook returned status ${response.status}`,
response.statusText && `(${response.statusText})`,
responseBody && `: ${responseBody}`
]
.filter(Boolean)
.join(' ');
return json(
{
sent: false,
error: upstreamMessage,
upstreamStatus: response.status
},
{ status: 502 }
);
}
The frontend reads that JSON and shows the actual error:
const result = (await response.json().catch(() => ({}))) as TriggerWebhookResponse;
if (!response.ok) {
throw new Error(result.error ?? `Webhook request failed with status ${response.status}.`);
}
This helps during development and support. The editor can report a specific message, and the developer can tell whether the failure is a bad URL, an auth issue, a validation error, or an upstream server problem.
Step 10: Document production setup
The README should include more than bun run dev.
For this plugin, the production documentation includes:
- Required environment variables.
- Local HTTPS setup.
- Storyblok Tool Plugin registration.
- OAuth callback URL.
- Space-level settings.
- Deployment notes for Vercel or Netlify.
- Production caveats.
- A verification checklist.
The most important production environment variables are:
PUBLIC_STORYBLOK_TOOL_PLUGIN_SLUG=your-org@send-to-flowmotion
PUBLIC_DEBUG_APP_BRIDGE=false
PUBLIC_ENABLE_MOCK_CONTEXT=false
APP_CLIENT_ID=your-storyblok-app-client-id
APP_CLIENT_SECRET=your-storyblok-app-client-secret
APP_URL=https://your-plugin.example.com
Set APP_URL to the deployed HTTPS URL. It must match the URL used in Storyblok OAuth settings.
What changes in Next.js, Nuxt, or another framework?
The framework changes the file structure, not the architecture.
In SvelteKit, we used:
src/routes/+page.svelte
src/routes/api/config/+server.ts
src/routes/api/trigger-webhook/+server.ts
src/routes/api/connect/[...slugs]/+server.ts
src/lib/server/storyblok-auth.ts
src/lib/server/storyblok-management.ts
In Next.js, the same responsibilities map to:
app/page.tsx
app/api/config/route.ts
app/api/trigger-webhook/route.ts
app/api/connect/[...slugs]/route.ts
lib/server/storyblok-auth.ts
lib/server/storyblok-management.ts
In Nuxt, they map to:
app.vue or pages/index.vue
server/api/config.get.ts
server/api/trigger-webhook.post.ts
server/api/connect/[...slugs].ts
server/utils/storyblok-auth.ts
server/utils/storyblok-management.ts
The key decisions stay the same:
- Use App Bridge in the browser.
- Use OAuth on the backend.
- Read Management API data server-side.
- Keep private config out of the browser.
- Return explicit state to the UI.
Production hardening
The example is functional, but a production plugin should go further.
Recommended next steps:
- Store OAuth sessions in durable storage when deploying to serverless platforms.
- Validate App Bridge tokens server-side before accepting iframe requests.
- Add stricter request validation with a schema library.
- Add rate limiting for mutation or webhook routes.
- Add structured logging without exposing secrets.
- Avoid storing secret webhook tokens directly in Storyblok space-level settings when possible.
Storyblok space-level settings are useful for configuration, but they are not a secret manager. If the webhook URL contains sensitive tokens, consider storing a profile key in Storyblok and resolving the actual URL on your backend.
Business value
A Tool Plugin is valuable when it removes context switching for editors.
In this example, the editor does not need to copy a story ID, open another tool, or manually trigger an external workflow. The action is available next to the story they are already editing.
For developers and solution engineers, the value is also architectural:
- The plugin uses the editor context instead of duplicating UI.
- Configuration is managed per Storyblok space.
- Sensitive work stays on the backend.
- The same pattern can trigger webhooks, workflows, previews, translations, QA checks, or external publishing processes.
This is the main reason to build a Tool Plugin. It connects editorial work with operational workflows while keeping the integration controlled and maintainable.
Conclusion
Building an effective Storyblok Tool Plugin is less about the frontend framework and more about the integration boundaries.
Use App Bridge to get editor context. Use OAuth and the Management API on the backend. Store space-specific configuration in Storyblok. Forward sensitive requests from server routes. Give editors clear states and useful error messages.
SvelteKit works well for this because pages and server routes live in the same project. The same approach works with Next.js, Nuxt, or any framework that gives you a browser UI and backend endpoints.
The important part is the shape of the system: small steps, clear contracts, server-side secrets, and a plugin experience that fits naturally into the Storyblok Visual Editor.





















