Server-Side Rendering
In SSR, the server renders a complete page, sends it to the browser, and the server-side context is destroyed. Every preview update means a full round trip, there's no persistent process to receive events and refetch in place.
This contract holds regardless of your framework: Next.js, Nuxt, Remix, Astro SSR, Express, or custom Node.js.
The SSR Live Preview Contract
Every SSR implementation must satisfy these requirements:
Reload on change: The browser reloads when content changes (no in-place updates)
Hash on every request: The server receives the preview hash with each request
Per-request detection: Preview vs production is determined per request, not globally
Draft data fetching: Use Preview API when hash is present
No caching: Preview responses must never be cached
The Update Cycle
1. Editor types in CMS
↓
2. CMS emits change event via postMessage
↓
3. SDK (in browser) receives event
↓
4. SDK signals CMS: "reload needed" (ssr: true mode)
↓
5. CMS reloads iframe with current URL + hash
↓
6. Browser requests fresh page from server
↓
7. Server extracts hash, fetches draft content from Preview API
↓
8. Server renders HTML, browser displays updated previewFirst Request Correctness
The initial request already contains the hash. Your server must detect this immediately and fetch draft content.
// WRONG: Fetch published first, "fix" with hydration → causes flicker
export async function getServerSideProps() {
const data = await fetchDeliveryAPI();
return { props: { data } };
}
// CORRECT: Check preview context first
export async function getServerSideProps({ query }) {
const isPreview = !!query.live_preview;
const data = isPreview
? await fetchPreviewAPI(query.live_preview)
: await fetchDeliveryAPI();
return { props: { data } };
}Request-Scoped Clients
This is where many SSR implementations fail. Preview configuration must be request-scoped, not application-scoped.
The Wrong Way
// DON'T: Global instance mutated per request
const stack = contentstack.stack({ /* ... */ });
app.get('/*', async (req, res) => {
stack.livePreviewQuery(req.query); // Shared across all requests!
const data = await stack.contentType('page').entry().find();
res.render('page', { data });
});Under load, requests interleave. One request's preview hash contaminates another. Editors see each other's drafts, or production users see draft content.
The Right Way
// DO: New client per request
app.get('/*', async (req, res) => {
const stack = createContentstackClient(req.query.live_preview);
const data = await stack.contentType('page').entry().find();
res.render('page', { data });
// stack is garbage collected after response
});
function createContentstackClient(livePreviewHash) {
const config = {
apiKey: process.env.API_KEY,
deliveryToken: process.env.DELIVERY_TOKEN,
environment: process.env.ENVIRONMENT
};
if (livePreviewHash) {
config.live_preview = {
enable: true,
preview_token: process.env.PREVIEW_TOKEN,
host: 'rest-preview.contentstack.com'
};
}
const stack = contentstack.stack(config);
if (livePreviewHash) {
stack.livePreviewQuery({ live_preview: livePreviewHash });
}
return stack;
}Disable All Caching for Preview
if (isPreviewRequest(req)) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}Bypass CDN, application, and framework caches when the hash is present.
Client-Side SDK Initialization
Even in SSR, the Live Preview SDK runs in the browser. It handles postMessage communication and tells the CMS to reload the iframe on changes.
import ContentstackLivePreview from "@contentstack/live-preview-utils";
ContentstackLivePreview.init({
enable: true,
ssr: true, // Critical: SSR mode triggers reloads instead of callbacks
stackDetails: {
apiKey: "your-api-key",
environment: "your-environment"
}
});Hash Propagation in Navigation
The most common SSR preview bug: the hash gets dropped during navigation.
The editor opens a page, sees draft content, clicks a link, and suddenly sees published content. The link didn't include preview parameters.
function navigateTo(url) {
const currentUrl = new URL(window.location.href);
const newUrl = new URL(url, window.location.origin);
const previewHash = currentUrl.searchParams.get('live_preview');
if (previewHash) {
newUrl.searchParams.set('live_preview', previewHash);
['content_type_uid', 'entry_uid', 'locale'].forEach(param => {
const value = currentUrl.searchParams.get(param);
if (value) newUrl.searchParams.set(param, value);
});
}
window.location.href = newUrl.toString();
}Also check middleware and redirects — they often strip query parameters.
Framework Implementations
Next.js (App Router)
// app/layout.js
import LivePreviewInit from './LivePreviewInit';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<LivePreviewInit />
</body>
</html>
);
}
// app/LivePreviewInit.js
'use client';
import { useEffect } from 'react';
import ContentstackLivePreview from '@contentstack/live-preview-utils';
export default function LivePreviewInit() {
useEffect(() => {
ContentstackLivePreview.init({
enable: true,
ssr: true,
stackDetails: {
apiKey: process.env.NEXT_PUBLIC_API_KEY,
environment: process.env.NEXT_PUBLIC_ENVIRONMENT
}
});
}, []);
return null;
}
// app/[slug]/page.js
import { createContentstackClient } from '@/lib/contentstack';
export default async function Page({ params, searchParams }) {
const client = createContentstackClient(searchParams.live_preview);
const data = await client.getEntry(params.slug);
return <PageContent data={data} />;
}Next.js (Pages Router)
// pages/[slug].js
export default function Page({ data }) {
useEffect(() => {
ContentstackLivePreview.init({ enable: true, ssr: true, stackDetails: { /* ... */ } });
}, []);
return <PageContent data={data} />;
}
export async function getServerSideProps({ query }) {
const client = createContentstackClient(query.live_preview);
const data = await client.getEntry(query.slug);
return { props: { data } };
}Nuxt.js
// pages/[slug].vue
<script setup>
const route = useRoute();
const livePreviewHash = route.query.live_preview;
const { data } = await useFetch('/api/content', {
query: { slug: route.params.slug, live_preview: livePreviewHash }
});
onMounted(() => {
import('@contentstack/live-preview-utils').then(({ default: LP }) => {
LP.init({ enable: true, ssr: true, stackDetails: { /* ... */ } });
});
});
</script>Express/Node.js
app.get('/*', async (req, res) => {
const livePreviewHash = req.query.live_preview;
const client = createContentstackClient(livePreviewHash);
const data = await client.getEntry(req.path);
if (livePreviewHash) {
res.set('Cache-Control', 'no-store');
} else {
res.set('Cache-Control', 'public, max-age=3600');
}
const html = renderPage(data, livePreviewHash);
res.send(html);
});Performance Expectations
SSR preview is inherently slower than CSR:
Operation | CSR Preview | SSR Preview |
|---|---|---|
Change to visible | ~100-300ms | ~500-2000ms |
Network round trips | 1 (API fetch) | 2 (page load + API) |
Server CPU | None | Full render |
Perceived feel | Instant | Noticeable pause |
If preview speed is critical, consider using CSR mode for preview even if production uses SSR.
Debugging SSR Preview
Check in order:
Is the hash in the URL? Check the iframe src in browser devtools
Is the SDK initializing? Check console for init logs
Is ssr: true set? Check your init configuration
Is the server seeing the hash? Add server logging
Is the server using Preview API? Log API endpoints being called
Is caching disabled? Check response headers
Frequently asked questions
Why does SSR live preview require a full page reload on each change?
In SSR, the server-side render context is destroyed after each request. Without a persistent process to apply updates in place, the CMS must reload the iframe to get a fresh server render.
How do I ensure the first SSR request shows draft content without flicker?
Detect the preview hash on the initial request and fetch draft content from the Preview API immediately. Avoid fetching published content first and trying to correct it during hydration.
Why must the Contentstack client be request-scoped in SSR preview?
A global, mutated client can leak one request’s preview hash into another under load. Creating a new client per request prevents cross-request contamination of preview and production responses.
What caching headers should be set for SSR preview responses?
Disable caching when the preview hash is present (for example: Cache-Control: no-store, no-cache, must-revalidate; plus Pragma: no-cache and Expires: 0). Also bypass CDN, framework, and application caches for preview requests.
How do I prevent the live preview hash from being dropped during navigation?
Propagate live_preview and related query parameters (content_type_uid, entry_uid, locale) into outgoing links and redirects. Also verify middleware and redirect rules do not strip query parameters.