How to Enable the Storyblok Visual Editor in Your Hybrid Astro Project

Enabling the Storyblok visual editor in your Astro project can streamline your content management process. Traditionally, this required deploying two versions of your site (SSG + SSR), but with Astro’s Hybrid mode, we can simplify things. Let’s walk through the steps to make this work with minimal fuss.

Prerequisites

Before diving into the tutorial, ensure you have the following set up to successfully enable the Storyblok Visual Editor in your hybrid Astro project:

  • An Astro project with an active astro-storyblok integration.

  • A global layout wrapper included in your template for a seamless editing experience.

1. Set Up Environment Variables

First, you need to define two environment variables in your astro.config.mjs. This allows your project to use the necessary credentials without hardcoding them. Now add your STORYBLOK_SPACE_ID & SITE_LANG to your .env.

Also make sure Astro is in Hybrid mode.

experimental: {
    env: {
      schema: {
        STORYBLOK_SPACE_ID: envField.number({
          context: "server",
          access: "public",
          optional: false,
        }),
        SITE_LANG: envField.string({
          context: "client",
          access: "public",
          default: "en",
        }),
      },
    },
  },

2. Create Your bridge component

create src/components/SbBridge.astro

<script>
  import { loadStoryblokBridge } from "@storyblok/astro";
  document.addEventListener("DOMContentLoaded", () => {
    
    loadStoryblokBridge().then(() => {
      const { StoryblokBridge, location } = window;

      const storyblokInstance = new StoryblokBridge({
        /* preventClicks: true, */
      });

      storyblokInstance.on(["published", "change"], (event: any) => {
        if (!event.slugChanged) {
          location.reload();
        }
      });
    });
  });
</script>

3. Edit Your Layout

Next, set up your project for Static Site Generation (SSG) and create or edit your layout.astro. Here, you’ll add bridge?: boolean; to the props to enable the Storyblok bridge script on your CMS route.

---
import SbBridge from "../components/SbBridge.astro";
import { SITE_LANG } from "astro:env/client";
import { ViewTransitions } from "astro:transitions"; 

interface Meta {
  title?: string;
  description?: string;
  image?: string;
}
interface Props {
  meta?: Meta;
  bridge?: boolean;
  lang?: string;
}
const { bridge, meta, lang } = Astro.props;
---

<!doctype html>
<html lang={lang || SITE_LANG}>
  <head>
    <meta charset="UTF-8" />
    <title>{meta?.title}</title>
    <meta name="description" content={meta?.description} />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />

    {
      bridge && (
        <script
          src="//app.storyblok.com/f/storyblok-v2-latest.js"
          type="text/javascript"
          is:inline
        />
      )
    }

    {!bridge && <ViewTransitions />}
  </head>
  <body>
    <header>...</header>
    <slot />
    <footer>...</footer>
  </body>
</html>

{ bridge && <SbBridge /> }

4. Create a CMS Route

Now, add a route for your CMS at /src/pages/cms/index.astro. This will handle incoming requests from Storyblok’s visual editor.

---
import { SITE_LANG } from "astro:env/client";
import { useStoryblokApi } from "@storyblok/astro";
import StoryblokComponent from "@storyblok/astro/StoryblokComponent.astro";
import type { ISbResult } from "@storyblok/astro";
import Layout from "../../layouts/Layout.astro";

export const prerender = false;
const api = useStoryblokApi();
const slug = Astro.url.searchParams.get("url");

if (!slug) {
  return Astro.redirect("/404");
}

/* SOME HELPER FUNCTIONS */

function getLangFromUrl(url: URL, locales: string[]) {
  const [, lang] = url.pathname.split("/");
  if (locales.includes(lang)) return lang as string;
  return SITE_LANG;
}

function getDataUrl(url: URL, locales: string[]) {
  const [, lang] = url.pathname.split("/");
  let baseUrl = url.pathname;

  if (locales.includes(lang)) {
    baseUrl = url.pathname.replace(`/${lang}`, "");
  }
  return baseUrl;
}

const getStory = async (slug: string | undefined, lang: string | undefined) => {
  const { data } = (await api.get(
    `cdn/stories/${slug !== undefined ? slug : "index"}`,
    {
      version: "draft",
      language: !lang || lang === SITE_LANG ? "default" : lang,
    },
  )) as ISbResult;
  return data.story.content;
};

/* WE GET SUPPORTED LOCALES FROM THE SPACE DATA */
const { data } = (await api.get("cdn/spaces/me", {})) as SpaceObj;
const locales = [SITE_LANG, ...data.space.language_codes];
const storyUrl = new URL(slug, Astro.url.origin);
const lang = getLangFromUrl(storyUrl, locales);
const dataUrl = getDataUrl(storyUrl, locales);

/* Add your own Interface of your blok instead of any */
let blok: any | null = null;

try {
  blok = await getStory(dataUrl, lang);
} catch (e) {
  console.log("error", e);
}
---

<Layout bridge={true}
  >{blok && <StoryblokComponent blok={blok} status="draft" />}
  {
    !blok && (
      <div class="grid h-full min-h-svh w-full place-items-center">
        <div class="text-center">
          <h1 class="text-4xl">Page Not Found</h1>
          <div>(or not saved)</div>
        </div>
      </div>
    )
  }
</Layout>

5. Implement Middleware for CMS Route

To ensure that this route only works within Storyblok, implement some middleware. This step restricts access and helps manage content securely.

import { defineMiddleware } from "astro:middleware";
import { STORYBLOK_SPACE_ID } from "astro:env/server";

export const onRequest = defineMiddleware(async (context, next) => {
  const { url, redirect } = context;

  /* EXPOSE CMS ROUTE ONLY FOR THE CMS */
  if (url.pathname.startsWith("/cms")) {
    const sbSpaceId = url.searchParams.get("_storyblok_tk[space_id]");
    if (!import.meta.env.DEV && sbSpaceId !== STORYBLOK_SPACE_ID.toString()) {
      return redirect("/404");
    }
  }

  return await next();
});