惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
T
ThreatConnect
SecWiki News
SecWiki News
F
Future of Privacy Forum
AWS News Blog
AWS News Blog
C
Cisco Blogs
A
Arctic Wolf
Vercel News
Vercel News
The GitHub Blog
The GitHub Blog
Scott Helme
Scott Helme
V
V2EX
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
K
Kaspersky official blog
G
Google Developers Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy International News Feed
C
Cyber Attacks, Cyber Crime and Cyber Security
N
News | PayPal Newsroom
Schneier on Security
Schneier on Security
NISL@THU
NISL@THU
Microsoft Azure Blog
Microsoft Azure Blog
量子位
The Hacker News
The Hacker News
Stack Overflow Blog
Stack Overflow Blog
Security Latest
Security Latest
M
Microsoft Research Blog - Microsoft Research
Google Online Security Blog
Google Online Security Blog
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
InfoQ
Google DeepMind News
Google DeepMind News
Y
Y Combinator Blog
The Cloudflare Blog
Microsoft Security Blog
Microsoft Security Blog
Martin Fowler
Martin Fowler
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Troy Hunt's Blog
F
Fox-IT International blog
S
Security @ Cisco Blogs
博客园 - 司徒正美
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
Comments on: Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LINUX DO - 最新话题
GbyAI
GbyAI
Project Zero
Project Zero
腾讯CDC
T
Tailwind CSS Blog

DEV Community

How to Get Your Renault / Dacia Radio Code for Free RAG 시스템 실전 구축 (v39) Retraction — scrml’s Living Compiler I built a fitness app where the AI roasts you for eating pizza (and hypes you when you PR) The Top SaaS Founder Communities on Discord (Beyond the AI Hype) I Built a Production-Grade Async Job Queue from Scratch — Here's Everything That Actually Happened How to watch SMS from multiple Android phones in one iOS app We Didn’t Want Another AI Wrapper — So We Explored a High-Speed Hermes Orchestrator for Engineering Crews Multi-tenant além do TenantId: problemas reais e aprendizados em sistemas .NET After failing 23 times, I am sharing How I Actually Prepare for a Tech Interview Every Single Time Now. I built an app that works like a nutritionist for your brain. Here's what happened in 7 days. GoBadge Dynamic: From Module Stats to Universal Badges LangGraph 워크플로우 템플릿 (v39) The git Commands You Forgot Exist (And Why AI Workflows Make Them Relevant Again) Six Levels of MCP Servers One container to replace Grafana + Loki + Tempo + Prometheus The Request/Response Cycle, HTTP, Auth, JWT, OAuth & Sessions — Explained Properly Python Week 3: We Stopped Repeating Ourselves (Loops!) Creating a Custom Grid Editor tool in Unreal Engine 我做了个付费 Telegram bot。Telegram Stars 实际给开发者多少钱,我算了一笔账。 I Got 96% Recall on LLM Hallucination Detection With No ML Model – Just 50 Lines of Python A practitioner's guide to getting more value out of AI coding: agent quality & token optimization How to Handle Telegram Albums in Telegraf I Built a Multilingual Spam Detection Dataset with 149K+ Messages Across 23 Languages How to Handle Telegram Albums in grammY RAG 시스템 실전 구축 (v38) Beyond Pip Install: Why Your AI Agent Needs a "Hermetic" Life-Support System to Survive Resume Building using HTML & CSS SpecFlow: Multi-Agent SDD in Cursor (4 phases, /approve, single code writer) Running ASR for smart homes in the NPU of Intel processors "Building a CI/CD Pipeline From Scratch: A Practical Guide for Developers (with GitHub Actions)" SpecFlow: SDD multi-agente en Cursor (4 fases, /approve, un solo escritor de código) How to Extract Your Full Team Hierarchy from HubSpot (the API doesn't expose it) Adobe Commerce Cloud now costs $40k/year. We migrated from Adobe Commerce to Magento Open Source — here's the honest breakdown .klickd v4.0.0 — Portable AI memory with constraints, strict schemas, and test vectors We Trust Third Party Code, It’s Time to Trust AI Generated Code LangGraph 워크플로우 템플릿 (v38) Sustainable AI Starts with Efficient AI Find Remove duplicated files in Google Drive How to Detect GPU Waste in a Kubernetes Cluster The Privacy Bug in My First Chrome Extension (And How to Avoid It) Serverless Mental Models: What They Don't Tell You Before You Build Preventing GPT hallucination in automated content pipelines: how I structure Make.com flows with data injection Hmm, where were we? AI Visibility Tools, Math Proofs, and Stripped Guardrails Shape Developer Landscape How AI and Electronics Are Changing Healthcare Devices: The Future of Smart Healthcare Author: Shivam Wakade | Founder, PrivSR Making Claude Sound Like Optimus Prime Understanding Reinforcement Learning with Human Feedback Part 5: Training the Reward Model with Loss Functions Learning Progress Pt.20 How Secure LoRa Communication Devices Work: Building the Future of Private and Long-Range Connectivity Author: Shivam Wakade | Founder, PrivSR How I Rebuilt an RPG Map Editor with Rust, React, and WASM Building a System That Automates YouTube Post-Production Building a 100% Serverless Digital Asset Packager in the Browser Game Recommended AI What is Human-In-The-Loop (HITL)? Deep Dive: React Server Components in TanStack Start Migrating off Google Analytics: Umami vs Plausible vs Fathom Building a Portfolio That Actually Demonstrates Software Engineering Async/Await in JavaScript: From Callbacks to Clean Code (2026) Benchmarking LLM Structured Outputs Angular 21 Multiselect Dropdown: A Migration-Friendly Component with Live Functional Tests ShareBox v5 — GPU transcoding, Netflix-style grid, and why I don't need Plex anymore TOML Schema is live Handling Duplicate Shopify Webhook Events (And Why You Must) Original Kubernetes Dashboard — retired upstream, upgraded to Angular 21. لماذا أسست ترينافو للتجار العرب الذين تتجاهلهم المنصات الغربية Construyendo un recomendador de películas en Python: de los datos al modelo When APIs Lie: A Lesson in Defensive Debugging Pope Leo XIV's AI Encyclical: What Builders Must Know (2026) Donna v0.3.0 HTB — MonitorsFour | Writeup The Free Tool You Trust Is the One You Should Fear the Most HTB — MonitorsFour | Writeup Fr 97. Embeddings and Vector Search: Semantic Search That Works Deep Dive: Building "Gravity Paint" - A Tactile Physics Instrument with React, Matter.js, and p5.js ABAP Unit Testing with Test Doubles and Mocking Frameworks: A Senior Architects Guide to Isolating Dependencies in SAP S/4HANA LeetCode Solution: 5. Longest Palindromic Substring kovax-react 0.8: Tailwind v4 preset, FormField adapters, ColorModeScript, and Storybook I built an AI résumé tool that refuses to lie about your experience The hat Azure Entra ID User & Role Management — Step-by-Step Practical Guide With A Simple Excercise The AI-Native Company: How a Single Founder Can Build Global Organizations Powered by AWS and an Ecosystem of Artificial Intelligences Building a Lightweight Remote MCP Knowledge Base on Cloudflare Workers Why I built Trinavo for the MENA merchants Western platforms ignore The N+1 Query That Killed Our Database, And How I Fixed It Docstrings vs Markdown Docs: What Should Developers Actually Write? Training Data Provenance: The Manifest Diff That Explains the Hash Add SVGIcons MCP to Claude Code and Find SVG Icons from Your Terminal 3 CLI Tools You Can Buy with Crypto — No KYC, No Subscriptions COSS Weekly: OpenClaw competitor NanoClaw Raises $12M, Dust Raises $40M, Sonar Acquires Gitar, and more How to know if you actually need mobile proxies (without buying any) Building Cursor for Community: A Buildathon Built on Time Pressure How we built a PII masking layer for LLM APIs — local detection, reversible tokens, one line to integrate Why MLFQ Was Way Ahead of Its Time Add Runtime Limits to Claude Agent Workflows I Built a Prompt Injection Detector with 98% Recall on Unseen Attacks. Here's Why Data Beat Architecture. 8 Vite Config Options Every Developer Should Know (Vite 8) Feature Flags That Forgot to Leave Why Trust Infrastructure Is Becoming the Hidden Layer of Donation Platforms XyPriss: Rethinking Core Performance and Zero-Trust Architecture in Modern Backends
Building an effective Storyblok Tool Plugin with SvelteKit
Roberto B. · 2026-05-26 · via DEV Community

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:

  1. The iframe UI.
  2. The app backend.
  3. 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Then start the app:

bun run dev

Enter fullscreen mode Exit fullscreen mode

The local app is available at:

https://localhost:5173

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

The send button is only enabled when three conditions are true:

let canSend = $derived(
    Boolean(context && configStatus === 'loaded' && config?.authenticated && config.configured)
);

Enter fullscreen mode Exit fullscreen mode

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, '*');
}

Enter fullscreen mode Exit fullscreen mode

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);
    };
});

Enter fullscreen mode Exit fullscreen mode

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
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

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'
};

Enter fullscreen mode Exit fullscreen mode

This is useful for screenshots, local UI work, and debugging. Keep it disabled in production:

PUBLIC_ENABLE_MOCK_CONTEXT=false

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

GET /api/config answers this question:

Can the current user and space send this story to Flowmotion?

Enter fullscreen mode Exit fullscreen mode

A configured response can look like this:

{
    "authenticated": true,
    "configured": true,
    "settings": {
        "hasWebhookUrl": true,
        "httpMethod": "POST"
    }
}

Enter fullscreen mode Exit fullscreen mode

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."]
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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'
    };
}

Enter fullscreen mode Exit fullscreen mode

The SvelteKit route delegates OAuth handling to the helper:

export const GET: RequestHandler = handleAuth;
export const POST: RequestHandler = handleAuth;

Enter fullscreen mode Exit fullscreen mode

The editor starts OAuth through:

/api/connect/storyblok

Enter fullscreen mode Exit fullscreen mode

The callback URL is:

/api/connect/callback

Enter fullscreen mode Exit fullscreen mode

For production, register the full HTTPS callback URL in Storyblok:

https://your-plugin.example.com/api/connect/callback

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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'
        }
    }
);

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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'
    })
});

Enter fullscreen mode Exit fullscreen mode

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.';
    }
}

Enter fullscreen mode Exit fullscreen mode

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)
});

Enter fullscreen mode Exit fullscreen mode

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 }
    );
}

Enter fullscreen mode Exit fullscreen mode

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}.`);
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.