Visual Preview in CMP
Enhance your editorial and development experience by connecting the web target of your Compose Multiplatform (CMP) project with Storyblok’s Visual Editor.
Configure the web target
Section titled “Configure the web target”Compose Multiplatform renders to Kotlin/Wasm on the web, so the web target is wasmJs. Remove the js target from shared/build.gradle.kts, and add an app source set that Android and iOS share, so the two apps fetch published content while web fetches drafts.
import org.jetbrains.kotlin.gradle.ExperimentalWasmDslimport org.jetbrains.kotlin.gradle.dsl.JvmTargetimport org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
kotlin { … js { browser() binaries.executable() }
@OptIn(ExperimentalWasmDsl::class) wasmJs { browser() binaries.executable() }
applyDefaultHierarchyTemplate { common { group("app") { withIos() withCompilations { it.target.platformType == KotlinPlatformType.androidJvm } } } } … sourceSets { … jsMain.dependencies { implementation(libs.wrappers.browser) } }}Remove the js target from the web module as well, since shared no longer provides it.
kotlin { js { browser() binaries.executable() }
@OptIn(ExperimentalWasmDsl::class) wasmJs { browser() binaries.executable() } …}Add expect declarations to App.kt so each target sets its own content version and initial story.
import androidx.compose.material3.MaterialThemeimport com.storyblok.ktor.Api.Config.Version…internal expect val contentVersion: Versioninternal expect val initialStoryKey: StoryKey
@Composablefun App() { MaterialTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val backStack = rememberNavBackStack(NavConfiguration, HomeKey) val backStack = rememberNavBackStack(NavConfiguration, initialStoryKey)
Storyblok( accessToken = "YOUR_ACCESS_TOKEN", version = Draft, version = contentVersion, region = EU, // Choose the correct region from your Space. …In the appMain source set (created by the app group), add App.app.kt to make Android and iOS fetch published content and open the home story.
package org.example.project
import com.storyblok.ktor.Api.Config.Version
internal actual val contentVersion: Version = Version.Published
internal actual val initialStoryKey: StoryKey = HomeKeyFor web, construct a StoryKey from the path of the preview URL to serve draft content to the Visual Editor.
package org.example.project
import com.storyblok.ktor.Api.Config.Versionimport kotlinx.browser.window
internal actual val contentVersion: Version = Version.Draft
internal actual val initialStoryKey: StoryKey = window.location.pathname.trim('/').let { slug -> if (slug.isEmpty()) HomeKey else StoryKey(slug = slug) }Stories in folders load the page from a nested path such as /articles/my-article, so reference the stylesheet and script by absolute path. Otherwise the browser requests them relative to the folder.
<link type="text/css" rel="stylesheet" href="styles.css"><link type="text/css" rel="stylesheet" href="/styles.css">…<script type="application/javascript" src="webApp.js"></script><script type="application/javascript" src="/webApp.js"></script>Connect your local environment
Section titled “Connect your local environment”Open Settings → Visual Editor and set the default environment to the URL of your local development server, https://localhost:8080/.
Generate a certificate for localhost with mkcert, from the root of your project.
mkcert -installmkcert -cert-file localhost.pem -key-file localhost-key.pem localhost 127.0.0.1The Kotlin Gradle plugin has no option for serving over HTTPS, so configure the underlying webpack development server directly. Create a webpack.config.d directory in the web module and add the following file to it.
// `__dirname` is the generated webpack package directory, four levels below the project root.const path = require("path");const projectRoot = path.resolve(__dirname, "../../../..");
config.devServer = config.devServer || {};
// The Visual Editor appends each story's real path to the preview URL, so serve index.html for// any path rather than 404ing on anything but `/`.config.devServer.historyApiFallback = true;
config.devServer.server = { type: "https", options: { key: path.join(projectRoot, "localhost-key.pem"), cert: path.join(projectRoot, "localhost.pem"), },};Start the development server.
./gradlew :webApp:wasmJsBrowserDevelopmentRunThe preview area now shows your project. Change a field value and save to check the update in real time.
Deploy the preview environment
Section titled “Deploy the preview environment”Build a deployable bundle with the following command, then serve the contents of webApp/build/dist/wasmJs/productionExecutable from any static host.
./gradlew :webApp:wasmJsBrowserDistributionLike the development server, the host must serve index.html for any path that doesn’t match a file. On Vercel, for example, add a vercel.json to the web module’s resources so the build copies it into the bundle.
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], "headers": [ { "source": "/(.*)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }] }, { "source": "/(.*).wasm", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] } ]}The file does two things:
- The rewrite returns
index.htmlfor story paths such as/homeor/articles/my-article. Vercel only applies it when no file matches the path, sowebApp.jsand the.wasmfiles are still served normally. - The headers control browser caching. The
.wasmfile names change whenever their content does, so browsers can keep them indefinitely.webApp.jskeeps the same name, so browsers must check for a new version on each load. The rule for all files comes first so that the.wasmrule can override it.
Generate a separate access token for each environment.
Related resources
Section titled “Related resources”Was this page helpful?
This site uses reCAPTCHA and Google's Privacy Policy (opens in a new window).Terms of Service (opens in a new window) apply.
Get in touch with the Storyblok community