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.


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)

  • The NEXT_PUBLIC_CMS_ORIGIN environment variable set in your .env file (see Step 0)


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

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 a tunnel to expose your dev server over HTTPS:

Bash
# Using cloudflared (free, no account required)
cloudflared tunnel --url http://localhost:3000

# Or using ngrok
ngrok http 3000

Then set the HTTPS tunnel URL (e.g., https://xyz.trycloudflare.com) 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:

Bash
# cloudflared (free, no account)
cloudflared tunnel --url http://localhost:3000

# or ngrok
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