Contentful Preview Environments for Content Review
A practical guide to building Contentful preview environments covering the Preview API, live preview, draft rendering, and editorial workflow integration with Node.js.
Contentful Preview Environments for Content Review
Overview
Contentful separates content management from content delivery, which means your editors write drafts in the Contentful web app but have no way to see what those drafts look like on your actual site until you build a preview environment. The Content Delivery API only returns published entries. The Content Preview API returns draft and changed entries, and building a preview server around it is one of the most valuable things you can do for your editorial team. Without it, your content workflow devolves into "publish it and see if it looks right," which is how broken pages end up in production.
I have built preview environments for three different Contentful-powered sites, ranging from a developer blog to a multi-locale marketing site with 40+ content types. The pattern is always the same: a separate Express.js server (or a mode toggle on your existing server) that hits the Preview API, wraps it in basic auth, and gives editors a URL they can open from directly inside the Contentful web app. This article walks through the full implementation.
Prerequisites
- A Contentful space with at least one content type and some draft (unpublished) entries
- Content Delivery API token and Content Preview API token (both available in Settings > API keys)
- Node.js 18+ installed
- Basic familiarity with Express.js and Pug (or your templating engine of choice)
- A deployment target for the preview server (can be the same host as production, different port)
Content Delivery API vs Content Preview API
Contentful provides two separate APIs with different base URLs and different access tokens:
| Feature | Content Delivery API (CDA) | Content Preview API (CPA) |
|---|---|---|
| Base URL | cdn.contentful.com |
preview.contentful.com |
| Token | Delivery token | Preview token |
| Returns | Published entries only | Draft + changed + published |
| Rate limit | 78 req/sec | 14 req/sec |
| Caching | CDN-cached, fast | No CDN caching, slower |
| Use case | Production site | Editorial preview |
The rate limit difference is important. The Preview API is significantly slower and has lower throughput. You should never use it for your production site. It exists solely for editorial review.
// The SDK constructor accepts a host parameter to switch APIs
var contentful = require("contentful");
// Production client -- hits cdn.contentful.com
var deliveryClient = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN
});
// Preview client -- hits preview.contentful.com
var previewClient = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN,
host: "preview.contentful.com"
});
The key difference in behavior: if you create an entry in Contentful and save it without publishing, the delivery client returns nothing. The preview client returns it immediately. If you publish an entry and then edit it (creating a new draft version), the delivery client returns the published version while the preview client returns the draft version with your unsaved changes.
Setting Up Preview Tokens
In the Contentful web app, go to Settings > API keys. Each API key has two tokens:
- Content Delivery API -- access token -- for production
- Content Preview API -- access token -- for preview
You can have multiple API keys with access to different environments (master, staging, etc.). For a preview setup, I recommend creating a dedicated API key called "Preview Server" so you can rotate it independently of your production key.
# .env for your preview server
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_DELIVERY_TOKEN=your_delivery_token
CONTENTFUL_PREVIEW_TOKEN=your_preview_token
CONTENTFUL_ENVIRONMENT=master
PREVIEW_AUTH_USER=editorial
PREVIEW_AUTH_PASS=a-strong-password-here
PORT=3001
Never commit preview tokens to source control. They grant read access to all unpublished content in your space, which may include drafts that are not ready for public eyes.
Building a Preview Server with Express.js
The preview server mirrors your production routing but uses the preview client. Here is the minimal setup:
// preview-server.js
var express = require("express");
var contentful = require("contentful");
var basicAuth = require("express-basic-auth");
var path = require("path");
var app = express();
var port = process.env.PORT || 3001;
// Basic auth to keep the preview private
app.use(basicAuth({
users: { [process.env.PREVIEW_AUTH_USER]: process.env.PREVIEW_AUTH_PASS },
challenge: true,
realm: "Content Preview"
}));
app.set("view engine", "pug");
app.set("views", path.join(__dirname, "views"));
app.use(express.static(path.join(__dirname, "static")));
// Preview client
var client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN,
host: "preview.contentful.com",
environment: process.env.CONTENTFUL_ENVIRONMENT || "master"
});
// Article listing
app.get("/articles", function(req, res, next) {
client.getEntries({
content_type: "blogPost",
order: "-fields.publishDate",
limit: 50
})
.then(function(response) {
res.render("preview_article_list", {
articles: response.items,
isPreview: true
});
})
.catch(next);
});
// Individual article
app.get("/articles/:slug", function(req, res, next) {
client.getEntries({
content_type: "blogPost",
"fields.slug": req.params.slug,
limit: 1
})
.then(function(response) {
if (response.items.length === 0) {
return res.status(404).render("404", { message: "Article not found" });
}
var entry = response.items[0];
res.render("preview_article", {
article: entry,
isPreview: true,
isDraft: !entry.sys.publishedAt,
hasChanges: entry.sys.publishedAt && entry.sys.updatedAt !== entry.sys.publishedAt
});
})
.catch(next);
});
app.listen(port, function() {
console.log("Preview server running on port " + port);
});
Draft Content Rendering and Status Indicators
The most useful feature of a preview environment is showing editors exactly what state each piece of content is in. Contentful entries have three possible states:
- Draft -- never published (
sys.publishedAtis null) - Changed -- published but has unpublished changes (
sys.updatedAt > sys.publishedAt) - Published -- current version matches published version
Build this into your templates:
//- views/preview_article.pug
extends template
block content
if isPreview
.preview-banner
if isDraft
.badge.badge-warning DRAFT - Not yet published
else if hasChanges
.badge.badge-info CHANGED - Unpublished edits
else
.badge.badge-success PUBLISHED - Up to date
span.preview-note You are viewing the preview environment
h1= article.fields.title
.article-meta
if article.fields.author
span.author= article.fields.author.fields.name
span.date= article.fields.publishDate
.article-body!= renderedContent
Add some CSS to make the preview banner impossible to miss:
/* static/css/preview.css */
.preview-banner {
position: sticky;
top: 0;
z-index: 9999;
background: #1a1a2e;
color: #fff;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
border-bottom: 3px solid #e94560;
}
.preview-banner .badge-warning {
background: #f0ad4e;
color: #000;
}
.preview-banner .badge-info {
background: #5bc0de;
color: #000;
}
.preview-banner .badge-success {
background: #5cb85c;
color: #fff;
}
.preview-note {
margin-left: auto;
opacity: 0.7;
font-style: italic;
}
Preview URL Configuration in Contentful
Contentful lets you configure preview URLs per content type so editors can click "Open preview" directly from the entry editor. Go to Settings > Content preview and add a preview platform:
- Name: Staging Preview
- Description: Preview draft content on staging server
Then for each content type, configure the URL pattern:
https://preview.yoursite.com/articles/{entry.fields.slug}
Contentful supports these tokens in preview URLs:
| Token | Value |
|---|---|
{entry.sys.id} |
Entry ID |
{entry.fields.slug} |
Value of the slug field |
{entry.fields.*} |
Any field value |
{env_id} |
Current environment ID |
{locale} |
Current locale |
For sites that use entry IDs in URLs instead of slugs:
https://preview.yoursite.com/articles?id={entry.sys.id}
Once configured, a "Open preview" button appears in the sidebar of the Contentful entry editor. This is the workflow editors actually want -- edit content, click preview, see it rendered on the real site.
Live Preview with Contentful Live Preview SDK
Contentful offers a Live Preview SDK that enables real-time content updates in your preview environment as editors type. This is more complex to set up but dramatically improves the editorial experience.
npm install @contentful/live-preview
The Live Preview SDK works client-side. You embed it in your preview templates and it listens for messages from the Contentful web app via postMessage:
// static/scripts/live-preview.js
// This runs in the browser, loaded only in preview mode
(function() {
var script = document.createElement("script");
script.src = "https://unpkg.com/@contentful/live-preview@3/dist/live-preview.umd.js";
script.onload = function() {
var ContentfulLivePreview = window.ContentfulLivePreview;
ContentfulLivePreview.init({
locale: "en-US",
enableInspectorMode: true,
enableLiveUpdates: true
});
// Subscribe to content updates
ContentfulLivePreview.subscribe("save", function(data) {
console.log("Content updated, reloading...");
window.location.reload();
});
};
document.head.appendChild(script);
})();
For live preview to work, your preview server must be loaded inside an iframe from the Contentful web app, or opened as a linked preview. The SDK communicates via window.postMessage between the Contentful editor and your preview frame.
Add inspector mode data attributes to your HTML so editors can click elements to jump to the corresponding field in the Contentful editor:
//- With inspector mode attributes
h1(data-contentful-entry-id=article.sys.id
data-contentful-field-id="title")= article.fields.title
.article-body(data-contentful-entry-id=article.sys.id
data-contentful-field-id="articleContent")!= renderedContent
When inspector mode is enabled, hovering over these elements shows a blue outline and clicking opens the field editor in Contentful. It is a huge productivity boost for editors fixing typos.
Environment-Based Preview (Dev/Staging)
Contentful environments let you branch your content, similar to git branches. You can create a staging environment, make changes there, and preview them without affecting the master environment. Your preview server should support switching between environments:
// Environment-aware client factory
function createContentfulClient(environment, isPreview) {
var config = {
space: process.env.CONTENTFUL_SPACE_ID,
environment: environment || "master"
};
if (isPreview) {
config.accessToken = process.env.CONTENTFUL_PREVIEW_TOKEN;
config.host = "preview.contentful.com";
} else {
config.accessToken = process.env.CONTENTFUL_DELIVERY_TOKEN;
}
return contentful.createClient(config);
}
// Route that accepts environment as a parameter
app.get("/preview/:environment/articles/:slug", function(req, res, next) {
var client = createContentfulClient(req.params.environment, true);
client.getEntries({
content_type: "blogPost",
"fields.slug": req.params.slug,
limit: 1
})
.then(function(response) {
if (response.items.length === 0) {
return res.status(404).render("404");
}
res.render("preview_article", {
article: response.items[0],
isPreview: true,
environment: req.params.environment
});
})
.catch(next);
});
This lets editors preview content in the staging environment at /preview/staging/articles/my-post and the master environment at /preview/master/articles/my-post.
Side-by-Side Published vs Draft Comparison
One of the most requested features from editorial teams is comparing the published version with the draft version. Build a comparison endpoint that fetches from both APIs:
// Side-by-side comparison route
app.get("/compare/:slug", function(req, res, next) {
var deliveryClient = createContentfulClient("master", false);
var previewClient = createContentfulClient("master", true);
var query = {
content_type: "blogPost",
"fields.slug": req.params.slug,
limit: 1
};
Promise.all([
deliveryClient.getEntries(query).catch(function() { return { items: [] }; }),
previewClient.getEntries(query)
])
.then(function(results) {
var published = results[0].items[0] || null;
var draft = results[1].items[0] || null;
if (!draft) {
return res.status(404).render("404", { message: "Entry not found in preview" });
}
res.render("preview_compare", {
published: published,
draft: draft,
isPreview: true,
slug: req.params.slug
});
})
.catch(next);
});
The comparison template renders them side by side:
//- views/preview_compare.pug
extends template
block content
.preview-banner
span.badge.badge-info COMPARISON VIEW
span.preview-note Published vs Draft
.row
.col-md-6
h3 Published Version
if published
.card
.card-body
h4= published.fields.title
.text-muted Updated: #{published.sys.updatedAt}
hr
.content-preview!= renderMarkdown(published.fields.articleContent)
else
.alert.alert-warning This entry has never been published
.col-md-6
h3 Draft Version
if draft
.card.border-warning
.card-body
h4= draft.fields.title
.text-muted Updated: #{draft.sys.updatedAt}
hr
.content-preview!= renderMarkdown(draft.fields.articleContent)
Preview for Rich Text Content
If your content type uses Contentful's Rich Text field instead of Markdown, you need the @contentful/rich-text-html-renderer package to convert the Rich Text JSON to HTML:
var richTextRenderer = require("@contentful/rich-text-html-renderer");
var richTextTypes = require("@contentful/rich-text-types");
var renderOptions = {
renderNode: {
[richTextTypes.BLOCKS.EMBEDDED_ASSET]: function(node) {
var asset = node.data.target;
if (!asset || !asset.fields) {
return "<p>[Unpublished embedded asset]</p>";
}
var url = asset.fields.file ? asset.fields.file.url : "";
var alt = asset.fields.title || "";
return '<img src="https:' + url + '" alt="' + alt + '" class="img-fluid" />';
},
[richTextTypes.BLOCKS.EMBEDDED_ENTRY]: function(node) {
var entry = node.data.target;
if (!entry || !entry.fields) {
return '<div class="alert alert-warning">[Unpublished embedded entry - ID: ' +
(entry ? entry.sys.id : "unknown") + ']</div>';
}
// Render based on content type
var contentType = entry.sys.contentType.sys.id;
if (contentType === "codeBlock") {
return '<pre><code class="language-' + (entry.fields.language || "text") + '">' +
entry.fields.code + '</code></pre>';
}
return '<div>' + JSON.stringify(entry.fields) + '</div>';
}
}
};
function renderRichText(richTextDocument) {
if (!richTextDocument) return "";
return richTextRenderer.documentToHtmlString(richTextDocument, renderOptions);
}
Notice the null checks on asset.fields and entry.fields. In preview mode, embedded entries and assets may be in draft state or may have been deleted. Your renderer must handle this gracefully rather than crashing with "Cannot read property 'file' of undefined."
Handling Unpublished References in Preview
This is where preview environments get tricky. When entry A references entry B, and entry B is unpublished, the Preview API still returns the reference -- but only if you include the appropriate depth. The Delivery API would simply omit it.
// Fetch with includes to resolve references
client.getEntries({
content_type: "blogPost",
"fields.slug": slug,
include: 3, // Resolve up to 3 levels of references
limit: 1
})
.then(function(response) {
var entry = response.items[0];
// Check if author reference is resolved
if (entry.fields.author && !entry.fields.author.fields) {
console.warn("Author entry is unresolvable (deleted or inaccessible)");
entry.fields.author = null;
}
// Check category references
if (entry.fields.categories) {
entry.fields.categories = entry.fields.categories.filter(function(cat) {
if (!cat.fields) {
console.warn("Unresolvable category reference: " + cat.sys.id);
return false;
}
return true;
});
}
return entry;
});
The include parameter defaults to 1. If you have deeply nested references (a page that references a section that references a card that references an image), you need include: 3 or higher, up to a maximum of 10. Each level costs more API response time.
Unresolvable references happen when a referenced entry has been deleted but the reference field has not been updated. In preview, this surfaces as objects with sys but no fields. Always check for the presence of fields before accessing nested properties.
Preview Authentication and Security
Basic auth is the minimum. For production preview environments, consider these layers:
// Option 1: Basic auth (simplest)
var basicAuth = require("express-basic-auth");
app.use(basicAuth({
users: { "editor": process.env.PREVIEW_AUTH_PASS },
challenge: true
}));
// Option 2: IP whitelist + basic auth
var allowedIPs = (process.env.ALLOWED_IPS || "").split(",").map(function(ip) {
return ip.trim();
});
app.use(function(req, res, next) {
var clientIP = req.ip || req.connection.remoteAddress;
if (allowedIPs.length > 0 && allowedIPs.indexOf(clientIP) === -1) {
return res.status(403).send("Forbidden");
}
next();
});
// Option 3: Token-based access via query parameter
app.use(function(req, res, next) {
var token = req.query.preview_token || req.headers["x-preview-token"];
if (token !== process.env.PREVIEW_ACCESS_TOKEN) {
return res.status(401).json({ error: "Invalid preview token" });
}
next();
});
Option 3 works well with Contentful's preview URL configuration because you can include the token in the URL pattern:
https://preview.yoursite.com/articles/{entry.fields.slug}?preview_token=your-secret-token
Add security headers to prevent the preview from being indexed:
app.use(function(req, res, next) {
res.setHeader("X-Robots-Tag", "noindex, nofollow");
res.setHeader("X-Frame-Options", "ALLOW-FROM https://app.contentful.com");
next();
});
Complete Working Example
Here is the full preview server pulling together everything discussed above. This is copy-paste ready:
// preview-server.js -- Complete Contentful preview server
var express = require("express");
var contentful = require("contentful");
var basicAuth = require("express-basic-auth");
var showdown = require("showdown");
var path = require("path");
require("dotenv").config();
var app = express();
var port = process.env.PORT || 3001;
var converter = new showdown.Converter({
tables: true,
ghCodeBlocks: true,
tasklists: true,
strikethrough: true
});
// -- Authentication --
var authUsers = {};
authUsers[process.env.PREVIEW_AUTH_USER || "editor"] = process.env.PREVIEW_AUTH_PASS || "changeme";
app.use(basicAuth({
users: authUsers,
challenge: true,
realm: "Content Preview"
}));
// -- Security headers --
app.use(function(req, res, next) {
res.setHeader("X-Robots-Tag", "noindex, nofollow");
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
res.setHeader("Pragma", "no-cache");
next();
});
// -- View engine --
app.set("view engine", "pug");
app.set("views", path.join(__dirname, "views"));
app.use(express.static(path.join(__dirname, "static")));
// -- Contentful clients --
function createClient(isPreview, environment) {
var config = {
space: process.env.CONTENTFUL_SPACE_ID,
environment: environment || process.env.CONTENTFUL_ENVIRONMENT || "master"
};
if (isPreview) {
config.accessToken = process.env.CONTENTFUL_PREVIEW_TOKEN;
config.host = "preview.contentful.com";
} else {
config.accessToken = process.env.CONTENTFUL_DELIVERY_TOKEN;
}
return contentful.createClient(config);
}
var previewClient = createClient(true);
var deliveryClient = createClient(false);
// -- Helpers --
function getEntryStatus(entry) {
if (!entry.sys.publishedAt) return "draft";
if (entry.sys.updatedAt !== entry.sys.publishedAt) return "changed";
return "published";
}
function renderMarkdown(content) {
if (!content) return "";
return converter.makeHtml(content);
}
function safeFields(entry) {
if (!entry || !entry.fields) return null;
return entry;
}
// -- Routes --
// Article listing with draft status indicators
app.get("/articles", function(req, res, next) {
previewClient.getEntries({
content_type: "blogPost",
order: "-sys.updatedAt",
limit: 100,
include: 1
})
.then(function(response) {
var articles = response.items.map(function(item) {
return {
entry: item,
status: getEntryStatus(item),
title: item.fields.title,
slug: item.fields.slug || item.sys.id,
updatedAt: item.sys.updatedAt,
publishedAt: item.sys.publishedAt
};
});
res.render("preview_article_list", {
articles: articles,
isPreview: true,
total: response.total
});
})
.catch(next);
});
// Individual article preview
app.get("/articles/:slug", function(req, res, next) {
previewClient.getEntries({
content_type: "blogPost",
"fields.slug": req.params.slug,
include: 3,
limit: 1
})
.then(function(response) {
if (response.items.length === 0) {
return res.status(404).send("Article not found: " + req.params.slug);
}
var entry = response.items[0];
var status = getEntryStatus(entry);
// Safely handle author reference
var author = safeFields(entry.fields.author);
var authorName = author ? author.fields.name : "Unknown Author";
// Render markdown content
var htmlContent = renderMarkdown(entry.fields.articleContent);
res.render("preview_article", {
article: entry,
authorName: authorName,
htmlContent: htmlContent,
status: status,
isPreview: true,
isDraft: status === "draft",
hasChanges: status === "changed"
});
})
.catch(next);
});
// Side-by-side comparison
app.get("/compare/:slug", function(req, res, next) {
var query = {
content_type: "blogPost",
"fields.slug": req.params.slug,
include: 2,
limit: 1
};
Promise.all([
deliveryClient.getEntries(query).catch(function() { return { items: [] }; }),
previewClient.getEntries(query)
])
.then(function(results) {
var published = results[0].items[0] || null;
var draft = results[1].items[0] || null;
if (!draft) {
return res.status(404).send("Entry not found");
}
res.render("preview_compare", {
published: published,
draft: draft,
publishedHtml: published ? renderMarkdown(published.fields.articleContent) : null,
draftHtml: renderMarkdown(draft.fields.articleContent),
isPreview: true,
slug: req.params.slug
});
})
.catch(next);
});
// Health check (no auth required -- placed before auth middleware in production)
app.get("/health", function(req, res) {
previewClient.getSpace()
.then(function(space) {
res.json({ status: "ok", space: space.name });
})
.catch(function(err) {
res.status(500).json({ status: "error", message: err.message });
});
});
// -- Error handling --
app.use(function(err, req, res, next) {
console.error("Preview server error:", err);
if (err.sys && err.sys.id === "AccessTokenInvalid") {
return res.status(500).send("Invalid Contentful Preview API token. Check CONTENTFUL_PREVIEW_TOKEN.");
}
if (err.sys && err.sys.id === "RateLimitExceeded") {
return res.status(429).send("Contentful rate limit exceeded. The Preview API allows 14 requests/second. Wait and retry.");
}
res.status(500).send("Preview server error: " + err.message);
});
app.listen(port, function() {
console.log("Contentful preview server running on port " + port);
console.log("Environment: " + (process.env.CONTENTFUL_ENVIRONMENT || "master"));
console.log("Auth required: yes");
});
Install the dependencies:
npm install express contentful express-basic-auth showdown dotenv
Run it:
CONTENTFUL_SPACE_ID=abc123 \
CONTENTFUL_PREVIEW_TOKEN=prev_xxx \
CONTENTFUL_DELIVERY_TOKEN=del_xxx \
PREVIEW_AUTH_USER=editor \
PREVIEW_AUTH_PASS=secretpass \
node preview-server.js
Output:
Contentful preview server running on port 3001
Environment: master
Auth required: yes
Preview Deployment Strategies
There are three common approaches for deploying preview environments:
1. Separate server, same codebase. Run the same Express app with an environment variable toggling preview mode. This is the simplest and what I recommend for most teams.
var isPreviewMode = process.env.PREVIEW_MODE === "true";
var client = createClient(isPreviewMode);
2. Separate server, separate deployment. Deploy the preview server as a completely separate service on a different subdomain (e.g., preview.yoursite.com). This gives full isolation and lets you deploy preview changes without touching production.
3. Preview route on production server. Add a /preview path prefix to your existing production server. The route uses the Preview API while all other routes use the Delivery API. This is the lowest-overhead option but mixing preview and production traffic on the same process makes me nervous.
For DigitalOcean App Platform, a separate component works well:
# .do/app.yaml
services:
- name: web
source_dir: /
http_port: 8080
envs:
- key: NODE_ENV
value: production
- name: preview
source_dir: /
http_port: 3001
run_command: node preview-server.js
envs:
- key: PREVIEW_MODE
value: "true"
- key: PORT
value: "3001"
routes:
- path: /preview
Editorial Workflow Integration
The preview environment is most powerful when integrated into a complete editorial workflow. Here is a pattern I use with Contentful webhooks to notify editors when content is ready for review:
// Webhook endpoint to handle Contentful events
app.post("/webhook/contentful", express.json(), function(req, res) {
var topic = req.headers["x-contentful-topic"];
var entry = req.body;
// Entry saved (new draft)
if (topic === "ContentManagement.Entry.save") {
var slug = entry.fields.slug ? entry.fields.slug["en-US"] : null;
if (slug) {
var previewUrl = "https://preview.yoursite.com/articles/" + slug;
console.log("Draft updated -- preview at: " + previewUrl);
// Send notification to Slack, email, etc.
}
}
// Entry published
if (topic === "ContentManagement.Entry.publish") {
console.log("Entry published: " + entry.sys.id);
// Trigger cache invalidation on production
}
res.status(200).send("OK");
});
Preview Performance Considerations
The Preview API is slow. Expect 200-500ms per request compared to 20-50ms for the Delivery API. Here is how to deal with it:
Do not cache preview responses aggressively. The whole point is seeing fresh content. But you can cache for very short durations to handle rapid page reloads:
var NodeCache = require("node-cache");
var previewCache = new NodeCache({ stdTTL: 5 }); // 5-second TTL
function getCachedEntry(slug, callback) {
var cached = previewCache.get(slug);
if (cached) {
return callback(null, cached);
}
previewClient.getEntries({
content_type: "blogPost",
"fields.slug": slug,
include: 3,
limit: 1
})
.then(function(response) {
var entry = response.items[0] || null;
if (entry) {
previewCache.set(slug, entry);
}
callback(null, entry);
})
.catch(callback);
}
Limit include depth. Each level of include adds latency. If your preview page only needs the article and its author (one level deep), do not request include: 10.
Paginate listings. Fetching 1000 entries from the Preview API will be painfully slow. Keep listing pages to 20-50 items.
Mobile Preview Testing
Editors often need to check how content looks on mobile devices. Add a responsive preview toggle to your preview templates:
// Mobile preview route
app.get("/articles/:slug/mobile", function(req, res, next) {
previewClient.getEntries({
content_type: "blogPost",
"fields.slug": req.params.slug,
include: 2,
limit: 1
})
.then(function(response) {
if (response.items.length === 0) {
return res.status(404).send("Not found");
}
// Render in a mobile viewport wrapper
res.render("preview_mobile", {
article: response.items[0],
htmlContent: renderMarkdown(response.items[0].fields.articleContent),
isPreview: true
});
})
.catch(next);
});
//- views/preview_mobile.pug
doctype html
html
head
meta(name="viewport" content="width=device-width, initial-scale=1")
style.
body { max-width: 375px; margin: 0 auto; padding: 16px; }
img { max-width: 100%; height: auto; }
body
.preview-banner
span Mobile Preview (375px)
h1= article.fields.title
.article-body!= htmlContent
A simpler approach: add viewport toggle buttons to the preview banner that resize the iframe or viewport using JavaScript, without needing separate routes.
Common Issues and Troubleshooting
1. "The access token you sent could not be found" (401 error)
You are using the Delivery API token with the Preview API host, or vice versa. Each API has its own token. Double-check that CONTENTFUL_PREVIEW_TOKEN is the Content Preview API token, not the Content Delivery API token. They are different values in the same API key panel.
Error: The access token you sent could not be found.
Make sure to use the correct access token for the Content Preview API.
Fix: Go to Settings > API keys, copy the token labeled "Content Preview API -- access token."
2. Entry returns fields: {} or missing fields
This happens when the entry exists but the fields are empty or the entry is in a locale you did not request. The Preview API returns entries even if all fields are empty (the Delivery API would not). Add locale handling:
client.getEntries({
content_type: "blogPost",
locale: "en-US", // Explicitly request locale
"fields.slug": slug,
limit: 1
});
3. "Rate limit exceeded" (429 error)
The Preview API rate limit is 14 requests per second, roughly 5x lower than the Delivery API. If multiple editors are actively previewing or if your page makes multiple API calls per render, you will hit this. Implement request queuing:
var requestQueue = [];
var processing = false;
function throttledRequest(queryFn) {
return new Promise(function(resolve, reject) {
requestQueue.push({ fn: queryFn, resolve: resolve, reject: reject });
processQueue();
});
}
function processQueue() {
if (processing || requestQueue.length === 0) return;
processing = true;
var item = requestQueue.shift();
item.fn()
.then(item.resolve)
.catch(item.reject)
.finally(function() {
processing = false;
setTimeout(processQueue, 75); // ~13 req/sec to stay under limit
});
}
4. Embedded entries show as [object Object] or crash with undefined
When an entry references another entry that has been deleted or is not resolvable, the SDK returns an object with sys but no fields. Your templates crash because they try to access entry.fields.title on an object without fields. Always guard reference access:
var authorName = (entry.fields.author && entry.fields.author.fields)
? entry.fields.author.fields.name
: "[Author not available]";
5. Preview shows published version, not draft
Verify you are using host: "preview.contentful.com" in your client configuration. The default host is cdn.contentful.com. Also verify the environment matches -- if you are editing content in the staging environment but your preview server points to master, you will not see your changes.
Best Practices
Keep the preview server separate from production. Mixing Preview API calls into your production server risks accidentally serving draft content to real users if a condition is wrong. A separate server or at minimum a separate process eliminates this class of bug entirely.
Always add
X-Robots-Tag: noindexheaders. Search engines will crawl your preview server if they find a link to it. Draft content appearing in Google results is embarrassing and potentially harmful. Block indexing at the HTTP header level, not just robots.txt.Use short-lived cache TTLs (5-10 seconds max). Editors expect to see their changes immediately. A 60-second cache defeats the purpose of preview. But a 5-second cache prevents duplicate API calls when editors refresh rapidly.
Log Preview API usage. Track how many requests your preview server makes per minute. The 14 req/sec rate limit sounds generous until you have 10 editors previewing simultaneously and each page render triggers 3 API calls.
Implement a status banner on every preview page. Editors will forget they are looking at a preview and report "bugs" that are actually draft content issues. A persistent, brightly colored banner prevents confusion and saves developer time.
Match your production rendering pipeline exactly. If your production site uses Showdown with specific options, your preview server must use the same library with the same options. Any rendering differences between preview and production undermine trust in the preview environment.
Set up Contentful preview URLs for every content type. The "Open preview" button in the Contentful sidebar is the single most impactful feature for editorial adoption. If editors have to manually construct preview URLs, they will not use the preview server.
Rotate preview auth credentials on a schedule. When editors leave the team, update the preview password. This is easy to forget because the preview server is not as visible as production credentials.