Skip to content

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.

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.

shared/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import 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.

webApp/build.gradle.kts
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.

shared/src/commonMain/kotlin/org/example/project/App.kt
import androidx.compose.material3.MaterialTheme
import com.storyblok.ktor.Api.Config.Version
…
internal expect val contentVersion: Version
internal expect val initialStoryKey: StoryKey
@Composable
fun 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.

shared/src/appMain/kotlin/org/example/project/App.app.kt
package org.example.project
import com.storyblok.ktor.Api.Config.Version
internal actual val contentVersion: Version = Version.Published
internal actual val initialStoryKey: StoryKey = HomeKey

For web, construct a StoryKey from the path of the preview URL to serve draft content to the Visual Editor.

shared/src/wasmJsMain/kotlin/org/example/project/App.wasmJs.kt
package org.example.project
import com.storyblok.ktor.Api.Config.Version
import 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.

webApp/src/webMain/resources/index.html
<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>

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.

Terminal window
mkcert -install
mkcert -cert-file localhost.pem -key-file localhost-key.pem localhost 127.0.0.1

The 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.

webApp/webpack.config.d/https.js
// `__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.

Terminal window
./gradlew :webApp:wasmJsBrowserDevelopmentRun

The preview area now shows your project. Change a field value and save to check the update in real time.

Build a deployable bundle with the following command, then serve the contents of webApp/build/dist/wasmJs/productionExecutable from any static host.

Terminal window
./gradlew :webApp:wasmJsBrowserDistribution

Like 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.

webApp/src/webMain/resources/vercel.json
{
"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.html for story paths such as /home or /articles/my-article. Vercel only applies it when no file matches the path, so webApp.js and the .wasm files are still served normally.
  • The headers control browser caching. The .wasm file names change whenever their content does, so browsers can keep them indefinitely. webApp.js keeps the same name, so browsers must check for a new version on each load. The rule for all files comes first so that the .wasm rule can override it.

Generate a separate access token for each environment.

Was this page helpful?

What went wrong?

This site uses reCAPTCHA and Google's Privacy Policy (opens in a new window).Terms of Service (opens in a new window) apply.