Next.js · App Router

Visual editing for Next.js, on WordPress.

Keep the App Router, your components and your hosting. Leiho adds a client component to your layout and one spread per editable element.

  1. 01

    Allow the editor to display your site

    Copy this into next.config.ts.

    next.config.ts
    import type { NextConfig } from 'next';
    
    const nextConfig: NextConfig = {
      async headers() {
        return [
          {
            source: '/:path*',
            headers: [
              { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://cms.example.com" },
            ],
          },
        ];
      },
    };
    
    export default nextConfig;
  2. 02

    Add the bridge

    Install the package, then add the component to app/layout.tsx. It’s already a client component.

    Terminal
    npm install leiho
    app/layout.tsx
    import { LeihoBridge } from 'leiho/react';
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="en">
          <body>
            {children}
            <LeihoBridge wpOrigin="https://cms.example.com" />
          </body>
        </html>
      );
    }
  3. 03

    Read the content

    Load the content in lib/leiho.ts, then read each page by its path.

    lib/leiho.ts
    import { cache } from 'react';
    import { loadLeihoContent, indexLeihoContent } from 'leiho/content';
    
    // One request per render, shared by every component.
    export const getContent = cache(async () => {
      const { data } = await loadLeihoContent({ url: 'https://cms.example.com/wp-json/leiho/v1/export' });
      return indexLeihoContent(data);
    });
    app/[[...slug]]/page.tsx
    import { notFound } from 'next/navigation';
    import { getContent } from '@/lib/leiho';
    
    export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
      const { slug = [] } = await params;
      const page = (await getContent()).byPath('/' + slug.join('/'));
      if (!page) notFound();
    
      return (
        <main data-wp-post={page.id}>
          <h1 data-wp-field="_title">{page.title}</h1>
        </main>
      );
    }

    Serverless host with a read-only disk? Pass snapshot: false to loadLeihoContent.

  4. 04

    Mark what editors can change

    Spread field() on each element editors should change. Always render the element, even when its value is empty.

    JSX
    import { field, entry } from 'leiho';
    // field() writes data-wp-post + data-wp-field, entry() names a section.
    // Flexible content rows: block(page, 'sections', i, 'Label').
    
    <section {...entry(page, 'Hero')}>
      <h1 {...field(page, 'hero_title')}>{page.fields.hero_title}</h1>
      <img {...field(page, 'hero_image')} src={page.fields.hero_image?.url} alt={page.fields.hero_image?.alt} />
      {page.fields.cards?.map((card, i) => (
        <article key={i} {...field(page, `cards.${i}.title`)}>{card.title}</article>
      ))}
    </section>

Replace https://cms.example.com with your WordPress address. In wp-admin, Leiho → Get started shows these steps with your address filled in, checks each one, and can give the whole setup to your coding agent as one prompt.

Edit the real site.

Free and open source. Try it on a real WordPress, in your browser.