> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# Visual Editor

**Publié le** : 2026-08-14
**Catégorie** : Advanced

Integrate the Lynkow Visual Editor to let CMS admins edit page content directly on your website. The admin dashboard opens your site in an iframe overlay, and the SDK on your site communicates via PostMessage to enable inline editing with real-time preview.

> **Reference** - This guide is a tutorial. For exhaustive, always-current signatures and field lists, see the auto-generated reference:
> 
> - SDK: [Lynkow SDK](/docs/sdk)

---

## Prerequisites

Before starting, ensure you have:

- A **Lynkow site** with at least one Site Block (page section) configured in the admin dashboard
- A **Next.js 15** frontend with the App Router
- The **`lynkow`**** SDK** installed:

```bash
npm install lynkow
```

- The site's **Preview URL** configured in the Lynkow admin under **Settings > Site > Preview URL** (covered in [Step 6](#step-6-configure-preview-url-in-admin))
- The **`NEXT_PUBLIC_CMS_ORIGIN`** environment variable set in your `.env` file (see [Step 0](#step-0-environment-variable))

---

## Step 0: Environment Variable

The Visual Editor requires knowing the origin of the Lynkow admin dashboard. This is used for CSP headers (to allow iframe embedding) and PostMessage origin validation (to accept only messages from the dashboard).

Add this variable to your `.env.local` (development) and your production environment:

```bash
# REQUIRED — the Visual Editor will not work without this
NEXT_PUBLIC_CMS_ORIGIN=https://manage.lynkow.com
```

> **Important:** `NEXT_PUBLIC_*` variables in Next.js are injected at **build time**, not runtime. After adding or changing this variable, you must **rebuild and redeploy** your site.

---

## Step 1: Configure CSP Headers

Browsers block pages from being displayed inside iframes by default. For the Visual Editor to work, your site must explicitly allow the Lynkow admin dashboard to embed it. This requires two header changes:

1. **Set ****`Content-Security-Policy: frame-ancestors`** to allow the CMS origin
2. **Remove ****`X-Frame-Options`** which would override CSP and block embedding

### Option A: SDK Middleware Helper (Recommended)

The SDK provides a Next.js middleware helper that handles both headers automatically. There are two ways to use it depending on whether you already have a custom middleware.

**Standalone (no existing middleware):**

```typescript
// middleware.ts
import { lynkowPreviewMiddleware } from 'lynkow/middleware/next'

export default lynkowPreviewMiddleware({
  cmsOrigin: process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com',
})

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}
```

**Composition (wrapping an existing middleware):**

```typescript
// middleware.ts
import { withLynkowPreview } from 'lynkow/middleware/next'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

function baseMiddleware(request: NextRequest) {
  // Your existing middleware logic here
  return NextResponse.next()
}

export default withLynkowPreview(baseMiddleware, {
  cmsOrigin: process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com',
})

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}
```

### Option B: Manual Headers in next.config.ts

If you prefer not to use the SDK middleware helper, configure the headers directly in your Next.js config.

```typescript
// next.config.ts
import type { NextConfig } from 'next'

const CMS_ORIGIN = process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com'

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: `frame-ancestors 'self' ${CMS_ORIGIN}`,
          },
          // X-Frame-Options is intentionally omitted so it does not
          // override the frame-ancestors directive above.
        ],
      },
    ]
  },
}

export default nextConfig
```

> **Important:** If your existing middleware or config already sets `X-Frame-Options: DENY` or `SAMEORIGIN`, you must remove it. That header takes precedence over `frame-ancestors` in some browsers and will block the iframe entirely.

---

## Step 2: Add the React Provider

Wrap your application in the `<LynkowVisualEditor>` provider. This component handles the PostMessage handshake with the admin dashboard, scans the page for editable fields, and activates the overlay UI.

It only activates when the page is loaded with the `?lynkow-preview=true` query parameter (which the admin dashboard appends automatically). In production, it adds zero overhead -- no event listeners, no DOM scanning, no network requests.

```typescript
// app/layout.tsx
import { LynkowVisualEditor } from 'lynkow/visual-editor/react'
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <LynkowVisualEditor cmsOrigin={process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com'}>
          {children}
        </LynkowVisualEditor>
      </body>
    </html>
  )
}
```

The `cmsOrigin` prop is required and must match the exact URL of your Lynkow admin dashboard (`https://manage.lynkow.com`). This is used to verify PostMessage origins for security -- messages from other origins are ignored.

---

## Step 3: Mark Editable Fields with Data Attributes

The Visual Editor discovers editable content on your page through HTML data attributes. When the page loads inside the iframe, the SDK scans the DOM for these attributes and sends the field map to the admin dashboard.

There are four attributes:

| Attribute | Purpose | Example |
| --- | --- | --- |
| `data-lynkow-block` | Marks a container as an editable block. Must match a Site Block slug in the CMS. | `data-lynkow-block="header"` |
| `data-lynkow-field` | Marks an element as an editable field within a block. | `data-lynkow-field="title"` |
| `data-lynkow-type` | Field type. Determines what editor UI the admin sees. | `data-lynkow-type="text"` |
| `data-lynkow-label` | Human-readable label shown in the editor overlay. | `data-lynkow-label="Page Title"` |


Supported field types: `text`, `image`, `richtext`, `array`, `object`, `boolean`, `number`, `select`, `color`, `url`, `email`, `date`.

Here is a complete page component with annotated editable fields:

```typescript
// app/page.tsx
import { lynkowClient } from '@/lib/lynkow'

export default async function HomePage() {
  // Fetch block data from the Lynkow SDK at build/request time
  const header = await lynkowClient.getBlock('header')

  return (
    <main>
      {/* Block container -- slug must match a Site Block in the CMS */}
      <section data-lynkow-block="header">

        {/* Image field */}
        <img
          src={header.logo}
          alt="Logo"
          data-lynkow-field="logo"
          data-lynkow-type="image"
          data-lynkow-label="Logo"
        />

        {/* Text field */}
        <h1
          data-lynkow-field="title"
          data-lynkow-type="text"
          data-lynkow-label="Page Title"
        >
          {header.title}
        </h1>

        {/* Rich text field */}
        <div
          data-lynkow-field="description"
          data-lynkow-type="richtext"
          data-lynkow-label="Description"
          dangerouslySetInnerHTML={{ __html: header.description }}
        />

        {/* Array field (e.g., navigation links) */}
        <nav
          data-lynkow-field="links"
          data-lynkow-type="array"
          data-lynkow-label="Navigation Links"
        >
          {header.links?.map((link: { label: string; url: string }, i: number) => (
            <a key={i} href={link.url}>
              {link.label}
            </a>
          ))}
        </nav>

      </section>
    </main>
  )
}
```

> **Nesting rule:** Every `data-lynkow-field` element must be a descendant of a `data-lynkow-block` element. Fields outside a block are ignored by the scanner.

---

## Step 4: Use Hooks for Live Preview Data

When an admin edits a field in the Visual Editor, the changes are sent to your site via PostMessage. The `useBlockData` hook subscribes to these messages and returns live data that updates in real-time, giving the admin an instant preview of their changes.

In production (outside the iframe), `useBlockData` simply returns the `initialData` you pass to it -- no subscriptions, no overhead.

### useBlockData

Returns the full block data object, updated in real-time during editing.

```typescript
'use client'

import { useBlockData } from 'lynkow/visual-editor/react'

interface HeaderData {
  title: string
  description: string
  logo: string
  links: { label: string; url: string }[]
}

export function Header({ initialData }: { initialData: HeaderData }) {
  // In preview mode: returns live data that updates as the admin edits
  // In production: returns initialData unchanged
  const data = useBlockData('header', initialData)

  return (
    <section data-lynkow-block="header">
      <img
        src={data.logo}
        alt="Logo"
        data-lynkow-field="logo"
        data-lynkow-type="image"
        data-lynkow-label="Logo"
      />
      <h1
        data-lynkow-field="title"
        data-lynkow-type="text"
        data-lynkow-label="Page Title"
      >
        {data.title}
      </h1>
      <div
        data-lynkow-field="description"
        data-lynkow-type="richtext"
        data-lynkow-label="Description"
        dangerouslySetInnerHTML={{ __html: data.description }}
      />
      <nav
        data-lynkow-field="links"
        data-lynkow-type="array"
        data-lynkow-label="Navigation Links"
      >
        {data.links?.map((link, i) => (
          <a key={i} href={link.url}>
            {link.label}
          </a>
        ))}
      </nav>
    </section>
  )
}
```

### useLynkowField

Returns the value of a single field. Useful when a field value is needed in isolation, for example to conditionally render a section.

```typescript
'use client'

import { useLynkowField } from 'lynkow/visual-editor/react'

export function AnnouncementBanner() {
  const message = useLynkowField('banner', 'message')
  const isVisible = useLynkowField('banner', 'visible')

  if (!isVisible) return null

  return (
    <div
      data-lynkow-block="banner"
      className="bg-blue-600 text-white p-4 text-center"
    >
      <p
        data-lynkow-field="message"
        data-lynkow-type="text"
        data-lynkow-label="Banner Message"
      >
        {message as string}
      </p>
    </div>
  )
}
```

### useIsPreviewMode

Returns `true` when the site is loaded inside the Visual Editor iframe. Use this to show preview-only UI or hide elements that should not appear during editing.

```typescript
'use client'

import { useIsPreviewMode } from 'lynkow/visual-editor/react'

export function Footer() {
  const isPreview = useIsPreviewMode()

  return (
    <footer>
      <p>Copyright 2026</p>
      {isPreview && (
        <div className="bg-yellow-100 text-yellow-800 p-2 text-sm text-center">
          You are viewing this page in the Lynkow Visual Editor.
        </div>
      )}
    </footer>
  )
}
```

---

## Step 5: Complete Example

A full working implementation with all the pieces wired together.

### File: middleware.ts

```typescript
// middleware.ts
import { lynkowPreviewMiddleware } from 'lynkow/middleware/next'

export default lynkowPreviewMiddleware({
  cmsOrigin: process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com',
})

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}
```

### File: lib/lynkow.ts

```typescript
// lib/lynkow.ts
import { createClient } from 'lynkow'

export const lynkowClient = createClient({
  siteId: process.env.LYNKOW_SITE_ID!,
  apiUrl: process.env.LYNKOW_API_URL || 'https://api.lynkow.com',
  apiToken: process.env.LYNKOW_API_TOKEN!,
})
```

### File: app/layout.tsx

```typescript
// app/layout.tsx
import { LynkowVisualEditor } from 'lynkow/visual-editor/react'
import type { ReactNode } from 'react'
import './globals.css'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <LynkowVisualEditor cmsOrigin={process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com'}>
          {children}
        </LynkowVisualEditor>
      </body>
    </html>
  )
}
```

### File: app/page.tsx

```typescript
// app/page.tsx
import { lynkowClient } from '@/lib/lynkow'
import { Header } from '@/components/header'
import { FeatureGrid } from '@/components/feature-grid'

export default async function HomePage() {
  // Server-side: fetch block data from the Lynkow API
  const [headerData, featuresData] = await Promise.all([
    lynkowClient.getBlock('header'),
    lynkowClient.getBlock('features'),
  ])

  return (
    <main>
      {/* Pass server-fetched data as initialData to client components */}
      <Header initialData={headerData} />
      <FeatureGrid initialData={featuresData} />
    </main>
  )
}
```

### File: components/header.tsx

```typescript
// components/header.tsx
'use client'

import { useBlockData } from 'lynkow/visual-editor/react'

interface HeaderData {
  title: string
  description: string
  logo: string
  ctaText: string
  ctaUrl: string
  links: { label: string; url: string }[]
}

export function Header({ initialData }: { initialData: HeaderData }) {
  const data = useBlockData('header', initialData)

  return (
    <header data-lynkow-block="header" className="py-16 text-center">
      <img
        src={data.logo}
        alt="Logo"
        className="mx-auto h-12 mb-8"
        data-lynkow-field="logo"
        data-lynkow-type="image"
        data-lynkow-label="Logo"
      />

      <h1
        className="text-5xl font-bold mb-4"
        data-lynkow-field="title"
        data-lynkow-type="text"
        data-lynkow-label="Headline"
      >
        {data.title}
      </h1>

      <div
        className="text-xl text-gray-600 max-w-2xl mx-auto mb-8"
        data-lynkow-field="description"
        data-lynkow-type="richtext"
        data-lynkow-label="Description"
        dangerouslySetInnerHTML={{ __html: data.description }}
      />

      <a
        href={data.ctaUrl}
        className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg"
        data-lynkow-field="ctaText"
        data-lynkow-type="text"
        data-lynkow-label="CTA Button Text"
      >
        {data.ctaText}
      </a>

      <nav
        className="mt-12 flex justify-center gap-6"
        data-lynkow-field="links"
        data-lynkow-type="array"
        data-lynkow-label="Navigation Links"
      >
        {data.links?.map((link, i) => (
          <a key={i} href={link.url} className="text-gray-700 hover:text-blue-600">
            {link.label}
          </a>
        ))}
      </nav>
    </header>
  )
}
```

### File: components/feature-grid.tsx

```typescript
// components/feature-grid.tsx
'use client'

import { useBlockData } from 'lynkow/visual-editor/react'

interface Feature {
  icon: string
  title: string
  description: string
}

interface FeaturesData {
  heading: string
  items: Feature[]
}

export function FeatureGrid({ initialData }: { initialData: FeaturesData }) {
  const data = useBlockData('features', initialData)

  return (
    <section data-lynkow-block="features" className="py-16 bg-gray-50">
      <h2
        className="text-3xl font-bold text-center mb-12"
        data-lynkow-field="heading"
        data-lynkow-type="text"
        data-lynkow-label="Section Heading"
      >
        {data.heading}
      </h2>

      <div
        className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto px-4"
        data-lynkow-field="items"
        data-lynkow-type="array"
        data-lynkow-label="Features"
      >
        {data.items?.map((feature, i) => (
          <div key={i} className="bg-white rounded-lg p-6 shadow-sm">
            <div className="text-4xl mb-4">{feature.icon}</div>
            <h3 className="text-xl font-semibold mb-2">{feature.title}</h3>
            <p className="text-gray-600">{feature.description}</p>
          </div>
        ))}
      </div>
    </section>
  )
}
```

### File: .env.local

```bash
# .env.local
LYNKOW_SITE_ID=your-site-uuid
LYNKOW_API_URL=https://api.lynkow.com
LYNKOW_API_TOKEN=your-api-token
# REQUIRED — Visual Editor will not work without this
NEXT_PUBLIC_CMS_ORIGIN=https://manage.lynkow.com
```

---

## Step 6: Configure Preview URL in Admin

The admin dashboard needs to know where your frontend is hosted so it can load it in the Visual Editor iframe.

1. Open the Lynkow admin dashboard
2. Go to **Settings > Site**
3. Find the **Preview URL** field
4. Enter your frontend URL (e.g., `https://www.example.com`)
5. Save

When an admin clicks "Visual Editor" in the admin dashboard, Lynkow appends `?lynkow-preview=true` to the Preview URL and loads it in an iframe. The SDK on your site detects this parameter and initiates the PostMessage handshake.

> **Development tip:** The admin dashboard runs on HTTPS (`https://manage.lynkow.com`), so the Preview URL **must also use HTTPS**. Browsers block HTTP iframes inside HTTPS pages (mixed content). For local development, use any HTTPS tunnel tool (for example `ngrok`, `localtunnel`, or `cloudflared`) to expose your dev server over HTTPS:
> 
> ```bash
> # Example with ngrok
> ngrok http 3000
> ```
> 
> Then set the HTTPS tunnel URL as your Preview URL. Update it to your production URL before going live.

---

## Troubleshooting

When the Visual Editor fails to connect, the admin dashboard displays an **"SDK Visual Editor not detected"** error. This means it did not receive a `lynkow:handshake` PostMessage from the iframe within 5 seconds.

Here are the possible causes and how to diagnose each one.

### Mixed Content (HTTPS → HTTP)

**Symptom:** The iframe is blank. No error in the dashboard console, no CSP violation — the browser silently refuses to load the iframe.

**Cause:** The admin dashboard runs on `https://manage.lynkow.com`. If your Preview URL uses `http://` (e.g., `http://localhost:3000`), the browser blocks it as mixed content. This is the most common issue during local development.

**Fix:** Use an HTTPS tunnel for local development (`ngrok`, `localtunnel`, `cloudflared`, etc.):

```bash
ngrok http 3000
```

Set the resulting HTTPS URL as your Preview URL in **Settings > Site > Preview URL**.

### CSP Blocking the Iframe

**Symptom:** The iframe is blank or shows an error page. The browser console on the admin dashboard shows:

```
Refused to display 'https://www.example.com/' in a frame because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'self'"
```

**Fix:** Ensure your CSP headers include the Lynkow admin origin:

```
Content-Security-Policy: frame-ancestors 'self' https://manage.lynkow.com
```

Also verify that no `X-Frame-Options` header is being set (by your hosting provider, CDN, or reverse proxy), as it overrides `frame-ancestors` in some browsers. Check all layers:

```bash
curl -I https://www.example.com | grep -i "frame\|content-security"
```

### Missing SDK / Provider Not Mounted

**Symptom:** The iframe loads your site correctly, but the editor overlay never appears.

**Fix:** Verify that `<LynkowVisualEditor>` is in the component tree. It must wrap the content of `<body>` in your root layout:

```typescript
// app/layout.tsx -- confirm this is present
<LynkowVisualEditor cmsOrigin={process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com'}>
  {children}
</LynkowVisualEditor>
```

Check the browser console inside the iframe (right-click the iframe > "Inspect frame" in Chrome DevTools) for import errors like `Cannot find module 'lynkow/visual-editor/react'`, which indicates the SDK is not installed.

### Wrong cmsOrigin

**Symptom:** The iframe loads and the SDK initializes, but the handshake never completes. No errors in the console.

**Fix:** The `cmsOrigin` prop must match the admin dashboard URL exactly, including the protocol and without a trailing slash:

```typescript
// Correct — always use the environment variable
<LynkowVisualEditor cmsOrigin={process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://manage.lynkow.com'} />

// Wrong -- hardcoded URL (will break if the dashboard URL changes)
<LynkowVisualEditor cmsOrigin="https://manage.lynkow.com" />

// Wrong -- trailing slash
<LynkowVisualEditor cmsOrigin="https://manage.lynkow.com/" />

// Wrong -- wrong protocol
<LynkowVisualEditor cmsOrigin="http://manage.lynkow.com" />
```

The SDK validates the PostMessage origin against this value. If they do not match, messages are silently ignored as a security measure.

### No Data Attributes on the Page

**Symptom:** The editor overlay appears but shows no editable fields.

**Fix:** At least one element with `data-lynkow-block` must exist on the page. Every `data-lynkow-field` must be nested inside a `data-lynkow-block` container. Verify with DevTools:

```javascript
// Run in the iframe's console
document.querySelectorAll('[data-lynkow-block]').length   // Should be >= 1
document.querySelectorAll('[data-lynkow-field]').length    // Should be >= 1
```

Also confirm that the block slugs in your HTML match the Site Block slugs configured in the CMS admin. Mismatched slugs are silently ignored.

### Preview URL Not Set

**Symptom:** Clicking "Visual Editor" in the admin dashboard does nothing or shows a configuration prompt.

**Fix:** Set the Preview URL in **Settings > Site > Preview URL**. This tells the admin dashboard which URL to load in the iframe.

### JavaScript Errors During Initialization

**Symptom:** The iframe loads but the SDK fails silently due to a runtime error.

**Fix:** Open DevTools on the iframe (right-click > "Inspect frame") and check the console for errors. Common issues:

- **Hydration mismatch:** If you use `useBlockData` in a component, ensure the component has the `'use client'` directive. Server Components cannot use hooks.
- **Missing ****`NEXT_PUBLIC_CMS_ORIGIN`****:** This is the most common cause. If the variable is undefined, the provider receives an empty string and the handshake silently fails. Verify your `.env.local` contains `NEXT_PUBLIC_CMS_ORIGIN=https://manage.lynkow.com` and that you **rebuilt** your app after adding it (Next.js injects `NEXT_PUBLIC_*` at build time, not runtime).
- **Conflicting middleware:** If another middleware returns a response before the Lynkow middleware runs, the CSP headers will not be set. Ensure the Lynkow middleware runs on all routes.

### Quick Diagnostic Checklist

| Check | How to Verify |
| --- | --- |
| `NEXT_PUBLIC_CMS_ORIGIN` set | `.env.local` contains `NEXT_PUBLIC_CMS_ORIGIN=https://manage.lynkow.com` |
| App rebuilt after env change | `NEXT_PUBLIC_*` vars are injected at build time — redeploy after changing |
| SDK installed | `npm ls lynkow` shows the package |
| Provider mounted | Search codebase for `LynkowVisualEditor` in a layout file |
| CSP headers set | `curl -I your-site.com` includes `frame-ancestors` with the CMS origin |
| No X-Frame-Options | `curl -I your-site.com` does NOT include `X-Frame-Options` |
| cmsOrigin correct | Matches admin URL exactly (no trailing slash) |
| Data attributes present | At least one `data-lynkow-block` element on the page |
| Block slugs match | HTML slugs match Site Block slugs in CMS admin |
| Preview URL configured | Set in admin Settings > Site > Preview URL |
| Preview URL uses HTTPS | No `http://` — browsers block mixed content in iframes |
| Query param present | URL ends with `?lynkow-preview=true` when loaded in iframe |