Securing Storyblok Visual Editor Previews Across Environments
This guide, part of Storyblok Visual Editor Integration, makes a Next.js site’s Visual Editor previews safe on every environment, from a developer’s laptop to production. It covers preview URLs per environment, validating the parameters the editor sends before any draft is shown, enabling draft mode, keeping the preview token and the bridge script away from readers, and running the setup over HTTPS locally. The bridge’s live-update mechanics are covered in real-time visual editing with the Storyblok bridge; this guide is about the boundary around it.
The Visual Editor is an iframe that loads your site’s preview URL with a set of query parameters: the story id, the language, the release, and a token derived from your space’s preview token and a timestamp. The site uses those parameters to recognise the editor, fetch the draft version of the story and load the Storyblok bridge, a script that tells the editor where each block sits on the page and sends every change back to the site. Without the bridge, the editor still shows the page, but clicking and live updates do not work.
The Problem
An agency connected a client’s site to the Visual Editor by pointing the preview URL at the production homepage and loading the bridge on every page. It seemed to work: editors could click blocks. But the site fetched drafts whenever a _storyblok parameter was present, so anyone who added that parameter to a URL saw unpublished content, including a product launch two weeks early. The bridge script also loaded for every reader, adding weight to every page, and the preview token sat in the client bundle.
How the Integration Works
Preview URLs. In the space settings, define the default preview URL and additional ones per environment, for example local development, staging and production. Point them at a draft route, not at the public pages directly, so the site can validate the request before showing anything unpublished.
Validating the editor. The editor appends _storyblok_tk[space_id], _storyblok_tk[timestamp] and _storyblok_tk[token]. The token is the SHA-1 hash of the space id, the preview token and the timestamp, joined with colons. Recompute it on the server, compare it, and reject timestamps older than an hour. Only then enable draft mode.
Draft mode. Next.js draft mode sets a cookie for this browser. In draft mode, every Storyblok fetch uses version=draft, the preview token and no caching.
The bridge. Load the bridge script only in draft mode. Listen for input events to re-render with the story the editor sends, and for published and change events to refresh the page from the server.
Editable blocks. Spread the attributes from storyblokEditable(blok) on each block’s root element. Draft responses include an _editable marker on each block, which the helper turns into attributes the editor uses to outline and select blocks.
Implementation
The draft route validates the editor’s parameters and enables draft mode:
// app/api/storyblok/preview/route.ts
import { createHash, timingSafeEqual } from "node:crypto";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(req: Request) {
const p = new URL(req.url).searchParams;
const spaceId = p.get("_storyblok_tk[space_id]") ?? "";
const timestamp = Number(p.get("_storyblok_tk[timestamp]"));
const token = p.get("_storyblok_tk[token]") ?? "";
const expected = createHash("sha1").update(`${spaceId}:${process.env.STORYBLOK_PREVIEW_TOKEN}:${timestamp}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - timestamp) < 3600;
const valid = token.length === expected.length && timingSafeEqual(Buffer.from(token), Buffer.from(expected));
if (!valid || !fresh || spaceId !== process.env.STORYBLOK_SPACE_ID) return new Response("forbidden", { status: 403 });
(await draftMode()).enable();
const slug = p.get("slug") ?? "home";
// Keep the editor's parameters so the bridge can talk to the editor.
const passthrough = [...p.entries()].filter(([k]) => k.startsWith("_storyblok")).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
redirect(`/${slug.replace(/^\/+/, "")}?${passthrough}`);
}
Configure the preview URL in Storyblok as https://staging.example.com/api/storyblok/preview?slug=; the editor appends the story’s full slug.
Pages fetch through one helper that respects draft mode:
// lib/story.ts
import { draftMode } from "next/headers";
import { getStoryblokApi } from "@/lib/storyblok";
export async function getStory(slug: string) {
const draft = (await draftMode()).isEnabled;
const { data } = await getStoryblokApi().get(`cdn/stories/${slug}`, {
version: draft ? "draft" : "published",
token: draft ? process.env.STORYBLOK_PREVIEW_TOKEN : process.env.STORYBLOK_PUBLIC_TOKEN,
}, draft ? { cache: "no-store" } : { next: { tags: [`story:${slug}`] } });
return { story: data.story, draft };
}
The page renders the story on the server and, in draft mode only, hands it to a small client component that loads the bridge. Because the component is only rendered in draft mode, readers never download the bridge or any editor code:
// components/LiveStory.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { StoryblokComponent } from "@storyblok/react";
export default function LiveStory({ initial }: { initial: any }) {
const [story, setStory] = useState(initial);
const router = useRouter();
useEffect(() => {
const script = document.createElement("script");
script.src = "https://app.storyblok.com/f/storyblok-v2-latest.js";
script.onload = () => {
const bridge = new (window as any).StoryblokBridge({ resolveRelations: ["article.author"] });
bridge.on("input", (e: any) => { if (e.story.id === initial.id) setStory(e.story); });
bridge.on(["published", "change"], () => router.refresh());
};
document.body.appendChild(script);
return () => { script.remove(); };
}, [initial.id, router]);
return <StoryblokComponent blok={story.content} />;
}
// app/[...slug]/page.tsx (excerpt)
const { story, draft } = await getStory(slug.join("/"));
return draft ? <LiveStory initial={story} /> : <StoryblokComponent blok={story.content} />;
Recent versions of the official SDK wrap the same steps in ready-made components for server components, which also keep the bridge out of published pages; the principles above still apply when using them.
HTTPS for local development
The Visual Editor runs on HTTPS and only loads preview URLs over HTTPS. Run the local development server with HTTPS, for example with Next.js’s experimental HTTPS flag or a local certificate created with a tool such as mkcert, and add https://localhost:3000/api/storyblok/preview?slug= as a preview URL named “Local”. Each developer then edits content in a development space and sees it rendered by their own branch.
Cookies inside the iframe
The editor loads the site in a cross-site iframe, so the draft mode cookie must be SameSite=None; Secure. Next.js sets it this way on HTTPS. If the page inside the editor shows published content after validation, inspect the cookie first; a proxy that rewrites cookies or a missing HTTPS setup is the usual cause.
One space or several
Teams choose between one Storyblok space with preview URLs for each environment, and separate spaces for development and production. With one space, every environment previews the same content, which keeps things simple but means developers test against live content and schema changes affect production editors immediately. With separate spaces, schema changes are made and tested in the development space first, then applied to production with the CLI or pipelines, and each space has its own preview token. Either way, each deployment must know which space and token it serves, and the draft route must check the space id, so a preview request from the development space can never enable draft mode on production.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Preview URL | the draft route, per environment | Validation before any draft is shown. |
| Token check | SHA-1 of space id, preview token, timestamp | Proves the request came from the editor. |
| Timestamp | reject older than one hour | Limits reuse of copied URLs. |
| Draft fetch | version=draft, preview token, no-store |
Fresh drafts, never cached for readers. |
| Bridge | loaded only in draft mode | No editor code for readers. |
| Editable attributes | storyblokEditable(blok) on each root element |
Click-to-edit works. |
| Local dev | HTTPS preview URL | The editor refuses HTTP. |
Gotchas & Edge Cases
- Relations in input events. Stories sent by the bridge contain unresolved relations unless the bridge is told which to resolve. Pass the same
resolveRelationslist as the fetch. - Blocks without a single root. Components that render fragments have nowhere to put editable attributes; wrap them in an element.
- Global content. Headers and footers stored in separate stories do not update live while editing a page; editors open those stories directly.
- Content Security Policy. Allow the Storyblok app as a frame ancestor for draft responses and the bridge script’s origin in
script-src, only in draft mode. - Languages. The editor passes
_storyblok_lang; fetch the draft with the matching language, or editors see the default language while editing a translation.
Worked Example
The agency rebuilt the integration with a draft route that validates the editor’s token and timestamp, draft mode for fetching, and a bridge loaded only in draft mode. A penetration test afterwards confirmed that adding _storyblok to public URLs no longer revealed anything, and the preview token disappeared from the client bundle. Removing the bridge from published pages cut the JavaScript downloaded by readers noticeably, and editors kept the same click-to-edit experience as before.
Editor Experience Details
Small details decide whether editors enjoy the Visual Editor. Give every block a meaningful name in the schema, so the outline and breadcrumbs in the editor read naturally. Make blocks with empty required fields visible in draft mode, with a dashed outline and a hint, instead of rendering nothing; otherwise editors add a block and cannot find it. Keep animations and lazy loading from hiding content in the editor: a carousel that shows only the first slide makes the others impossible to click. Where a block’s appearance depends on data that is not in the story, such as prices from a commerce system, render a clear placeholder in draft mode when the data is missing. Collect feedback from editors after the first weeks; most improvements are small changes to components rather than to the integration itself.
Rollout Checklist
- Point preview URLs at a draft route for each environment.
- Validate the editor’s token and timestamp before enabling draft mode.
- Fetch drafts with the preview token on the server, without caching.
- Load the bridge only in draft mode and handle input, change and published events.
- Add editable attributes to every block’s root element.
- Run local development over HTTPS with its own preview URL.
Frequently Asked Questions
Why not detect the editor by the _storyblok parameter alone?
Anyone can add it to a URL. Only the token check proves the request came from the editor.
Does the bridge work with server components?
The bridge runs in the browser, so live updates need a client component or a refresh of server-rendered content on each change, which the official SDK provides.
Can editors preview on the production domain?
Yes, if the draft route is deployed there and validated as above. Many teams prefer a staging deployment for previews to keep production traffic separate.
How do we preview releases?
The editor passes the release id; forward it to draft fetches with the release parameter so the preview shows the release’s content.