---
title: Visual Preview in CMP
description: Enhance your editorial experience by previewing draft content in a Compose Multiplatform project.
url: https://www.storyblok.com/docs/quickstarts/compose-multiplatform/visual-preview
---

# 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

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

```kotlin
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
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

```kotlin
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

```kotlin
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

```kotlin
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)
  }
```

> [!NOTE]
> The dev server also has to serve the app for those paths rather than only `/`—the `historyApiFallback` option in the next section takes care of that.

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

```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>
```

## Connect your local environment

Open **Settings** → **Visual Editor** and set the default environment to the URL of your local development server, `https://localhost:8080/`.

> [!WARNING]
> The preview area requires an `https` secure connection, even on localhost. Learn more in the [Visual Editor concept](/docs/concepts/visual-editor#ssl-certificate).

Generate a certificate for `localhost` with [`mkcert`](https://github.com/FiloSottile/mkcert), from the root of your project.

```bash
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

```javascript
// `__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"),
  },
};
```

> [!TIP]
> Add `localhost.pem` and `localhost-key.pem` to your `.gitignore` to keep per-machine certificates out of version control.

Start the development server.

```bash
./gradlew :webApp:wasmJsBrowserDevelopmentRun
```

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

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

```bash
./gradlew :webApp:wasmJsBrowserDistribution
```

Like the development server, the host must serve `index.html` for any path that doesn’t match a file. On [Vercel](https://vercel.com/docs/project-configuration#rewrites), 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

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

> [!NOTE]
> Learn more in the [Create Preview and Production Environments tutorial](https://www.storyblok.com/tp/create-preview-production-environments-and-deploy).

## Related resources

[Concept: Visual Editor](/docs/concepts/visual-editor)

[Storyblok Kotlin Compose SDK Reference](https://storyblok.github.io/storyblok-kotlin/storyblok-compose/index.html)

[Content Delivery API: Retrieve a Single Story](/docs/api/content-delivery/v2/stories/retrieve-a-single-story)

## Pagination

-   [Previous: Dynamic Navigation in CMP](/docs/quickstarts/compose-multiplatform/dynamic-routing)
-   [Next: Content Modeling in CMP](/docs/quickstarts/compose-multiplatform/content-modeling)
