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:
- Redis pub/sub -- Every content mutation (create, update, delete) publishes an event to a project-specific Redis channel (
live-events:{projectId}) - SSE proxy endpoint -- The
/api/live-previewroute authenticates the client, subscribes to the Redis channel via Upstash, and streams events to the browser - 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 contentEvent 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:
- Go to Dashboard > Developer
- Click Generate Frontend Token
- 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_SECRETbefore 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
| Variable | Description |
|---|---|
JWT_SECRET | Secret for verifying JWT tokens (server-side) |
UPSTASH_REDIS_REST_URL | Upstash Redis URL for pub/sub |
UPSTASH_REDIS_REST_TOKEN | Upstash Redis auth token |
NEXT_PUBLIC_LAIZY_BASE_URL | Base URL of your Laizy instance |
NEXT_PUBLIC_LAIZY_PREVIEW_TOKEN | Frontend JWT token for preview |
NEXT_PUBLIC_LAIZY_PROJECT_ID | Project ID to subscribe to |
Troubleshooting
Events not arriving
- Verify the
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENare 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
- Next.js Integration -- Full guide for using Laizy with Next.js
- Content Data API -- How mutations trigger live events
- Authentication -- Token types and scope differences