Skip to main content
Our new developer certification is live!

Server-Side Rendering

5 min read

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:

  1. Reload on change: The browser reloads when content changes (no in-place updates)

  2. Hash on every request: The server receives the preview hash with each request

  3. Per-request detection: Preview vs production is determined per request, not globally

  4. Draft data fetching: Use Preview API when hash is present

  5. 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 preview

First 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:

  1. Is the hash in the URL? Check the iframe src in browser devtools

  2. Is the SDK initializing? Check console for init logs

  3. Is ssr: true set? Check your init configuration

  4. Is the server seeing the hash? Add server logging

  5. Is the server using Preview API? Log API endpoints being called

  6. 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.