Laizy CMS
Integrations

Live Preview

Set up real-time content preview in your application with Laizy CMS.

Live Preview

Live preview lets you see content changes in real time before publishing. When content is created, updated, or deleted in the dashboard (or via the API), a Server-Sent Events (SSE) stream pushes updates to your application, triggering re-renders without a full page reload.

How It Works

The live preview system uses three components:

  1. Redis pub/sub -- Every content mutation (create, update, delete) publishes an event to a project-specific Redis channel (live-events:{projectId})
  2. SSE proxy endpoint -- The /api/live-preview route authenticates the client, subscribes to the Redis channel via Upstash, and streams events to the browser
  3. Client listener -- Your application connects to the SSE endpoint and re-fetches content when events arrive
Dashboard edit → API mutation → Redis publish → SSE proxy → Browser EventSource → Re-fetch content

Event Format

Each event published to the stream has this shape:

interface ContentUpdateEvent {
  projectId: string;
  modelName: string;            // e.g. "BlogPost"
  operation: 'create' | 'update' | 'delete';
  entryId?: string;             // ID of the affected entry
  timestamp: number;            // Unix timestamp in milliseconds
}

The SSE proxy also sends a connected event when the stream is established:

{ type: 'connected', projectId: string }

Setup with Next.js

Step 1: Generate a Frontend Token

Live preview requires a JWT token with at least content:read scope. Generate one from the dashboard:

  1. Go to Dashboard > Developer
  2. Click Generate Frontend Token
  3. Copy the token (starts with laizy_)

Or generate one programmatically with an admin token:

const managementClient = new ManagementClient({
  baseUrl: 'https://laizycms.com',
  apiToken: process.env.LAIZY_API_TOKEN!,
});

const { token } = await managementClient.generateFrontendToken('preview-token');

Step 2: Connect to the SSE Endpoint

The live preview endpoint is at /api/live-preview and accepts token and projectId as query parameters. EventSource does not support custom headers, so the JWT is passed via the URL.

'use client';

import { useEffect, useRef } from 'react';

interface LivePreviewOptions {
  baseUrl: string;
  token: string;
  projectId: string;
  onUpdate: (event: ContentUpdateEvent) => void;
  onConnect?: () => void;
  onError?: (error: Event) => void;
}

export function useLivePreview({
  baseUrl,
  token,
  projectId,
  onUpdate,
  onConnect,
  onError,
}: LivePreviewOptions) {
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    const url = `${baseUrl}/api/live-preview?token=${encodeURIComponent(token)}&projectId=${encodeURIComponent(projectId)}`;
    const es = new EventSource(url);

    es.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);

        if (data.type === 'connected') {
          onConnect?.();
          return;
        }

        if (data.type === 'error') {
          // Reconnection is handled automatically by EventSource
          return;
        }

        onUpdate(data);
      } catch {
        // Ignore malformed events
      }
    };

    es.onerror = (error) => {
      onError?.(error);
      // EventSource automatically reconnects
    };

    eventSourceRef.current = es;

    return () => {
      es.close();
    };
  }, [baseUrl, token, projectId, onUpdate, onConnect, onError]);
}

Step 3: Invalidate Content on Updates

Use the hook in a layout or page component to re-fetch content when changes arrive:

'use client';

import { useRouter } from 'next/navigation';
import { useLivePreview } from '@/hooks/use-live-preview';

export function LivePreviewProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const router = useRouter();

  useLivePreview({
    baseUrl: process.env.NEXT_PUBLIC_LAIZY_BASE_URL!,
    token: process.env.NEXT_PUBLIC_LAIZY_PREVIEW_TOKEN!,
    projectId: process.env.NEXT_PUBLIC_LAIZY_PROJECT_ID!,
    onUpdate: (event) => {
      console.log(`Content updated: ${event.operation} ${event.modelName}`);
      // Invalidate the Next.js router cache to trigger re-render
      router.refresh();
    },
    onConnect: () => {
      console.log('Live preview connected');
    },
  });

  return <>{children}</>;
}

Then wrap your layout:

// app/layout.tsx
import { LivePreviewProvider } from '@/components/live-preview-provider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <LivePreviewProvider>
          {children}
        </LivePreviewProvider>
      </body>
    </html>
  );
}

Step 4: Targeted Re-fetching (Optional)

For more granular control, filter events by model name and only re-fetch what changed:

useLivePreview({
  baseUrl: process.env.NEXT_PUBLIC_LAIZY_BASE_URL!,
  token: process.env.NEXT_PUBLIC_LAIZY_PREVIEW_TOKEN!,
  projectId: process.env.NEXT_PUBLIC_LAIZY_PROJECT_ID!,
  onUpdate: (event) => {
    if (event.modelName === 'BlogPost') {
      // Re-fetch only blog post data
      mutate('/api/blog-posts');
    }

    if (event.modelName === 'HeroSection') {
      // Re-fetch only hero section
      mutate('/api/hero');
    }
  },
});

Integrating with Next.js Draft Mode

For previewing unpublished drafts, combine live preview with Next.js Draft Mode:

// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get('secret');
  const slug = searchParams.get('slug');

  // Validate the preview secret
  if (secret !== process.env.DRAFT_MODE_SECRET) {
    return new Response('Invalid secret', { status: 401 });
  }

  (await draftMode()).enable();
  redirect(slug || '/');
}

Then in your page, use the admin token when draft mode is active to include draft content:

import { draftMode } from 'next/headers';
import { ManagementClient } from '@/lib/management-client';

export default async function BlogPage() {
  const isDraft = (await draftMode()).isEnabled;

  const client = new ManagementClient({
    baseUrl: process.env.LAIZY_BASE_URL!,
    apiToken: isDraft
      ? process.env.LAIZY_ADMIN_TOKEN!      // Sees drafts
      : process.env.LAIZY_FRONTEND_TOKEN!,   // Published only
  });

  const posts = await client.listContentData({
    modelName: 'BlogPost',
    status: isDraft ? undefined : 'published',
  });

  return (
    <div>
      {isDraft && <DraftModeBanner />}
      {posts.map((post) => (
        <BlogCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Security

The live preview endpoint enforces several security measures:

  • JWT validation -- The token is verified against the JWT_SECRET before any events are streamed
  • Project access check -- The token's organization must have access to the requested project
  • Cross-project filtering -- Events for other projects are silently dropped even if they appear on the same Redis channel
  • CORS headers -- The endpoint sets Access-Control-Allow-Origin: * for cross-origin access from preview environments

The preview token is passed as a URL query parameter because the EventSource API does not support custom headers. While the token is a read-only frontend token, avoid exposing it in client-side JavaScript on public-facing pages. Use environment variables and restrict the token scope to content:read.

Environment Variables

VariableDescription
JWT_SECRETSecret for verifying JWT tokens (server-side)
UPSTASH_REDIS_REST_URLUpstash Redis URL for pub/sub
UPSTASH_REDIS_REST_TOKENUpstash Redis auth token
NEXT_PUBLIC_LAIZY_BASE_URLBase URL of your Laizy instance
NEXT_PUBLIC_LAIZY_PREVIEW_TOKENFrontend JWT token for preview
NEXT_PUBLIC_LAIZY_PROJECT_IDProject ID to subscribe to

Troubleshooting

Events not arriving

  • Verify the UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set correctly
  • Check that your token has access to the project you are subscribing to
  • In development, check the server console for [Live Preview] log messages

Connection drops

The EventSource API automatically reconnects when a connection is lost. The SSE proxy sends a { type: 'error', message: 'Connection lost, reconnecting...' } event to the client during reconnection. No manual intervention is needed.

Stale content after update

If router.refresh() does not pick up the latest content, ensure your data fetching in Server Components is not cached. Use { cache: 'no-store' } or revalidateTag() for on-demand cache invalidation.

Next Steps

On this page