34.6 Setting Up Editorial Workflow and Preview
Right, so you’ve got your headless CMS pumping out content and your Hugo site statically generating from it. It’s a beautiful, modern setup. Until you realize you’ve just handed your editorial team a live grenade without the pin. They’re writing copy in a CMS interface that’s essentially a fancy text box, and they have to hit “publish” based on… what, exactly? Faith? A screenshot you sent them last Tuesday? This is how you get frantic 5 PM phone calls.
We’re fixing that. We’re building a preview workflow that doesn’t suck. The goal is simple: when an editor clicks “preview” in the CMS, they should see a page that looks exactly like it will on the live site, but built with their unpublished, draft content. No hacks, no “view this JSON,” no “just imagine the hero image is here.”
The Core Problem: Drafts and Builds
Hugo’s development server (hugo server) is a miracle of modern engineering. It’s how we preview things. But it’s running on our machine. The key is getting that capability to someone else, specifically, for a piece of content that hasn’t been published yet.
Most headless CMSs have a concept of “preview URLs.” They’ll take the draft content, bundle it up, and send a POST request or redirect the editor to a URL you configure, appending the draft content’s ID or payload. Our job is to catch that pass and score the goal.
Here’s the magic: we need a server that can:
- Receive a request from the CMS (usually with a secret token or a unique content ID).
- Trigger a new build of our Hugo site using that specific draft content.
- Serve the resulting page.
You have two main choices for this: a truly dynamic on-demand preview or a simpler, more robust “draft branch” setup. We’re going with the latter because it’s less of a hosting nightmare. Trust me.
The “Draft Branch” Strategy: K.I.S.S.
Create a separate preview environment. A dedicated branch in your Git repo (e.g., preview) that is always deployed to a unique URL (e.g., preview.yoursite.com). This is your staging ground.
Your headless CMS (like Contentful, Storyblok, or Decap) will have a preview URL setting. You’ll point it to this environment and pass the unique slug or ID of the draft content.
https://preview.yoursite.com/preview?secret=<TOKEN>&slug={{entry.slug}}
Now, in your Hugo site, you need to handle that /preview endpoint.
Hooking Up the Preview Handler
You’ll need to create a preview.html in your layouts folder. This page is the quarterback; it takes the incoming parameters, fetches the draft data, and renders the page.
This example assumes you’re using Decap (formerly Netlify CMS) and their identity widget, which handles auth for you. The logic is similar for others.
<!-- layouts/preview.html -->
<!DOCTYPE html>
<html>
<head>
<title>Preview</title>
<script src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
</head>
<body>
<script>
// Check for a #error hash in the URL (e.g., from a failed auth)
if (window.location.hash && window.location.hash === '#error') {
document.body.innerHTML = '<p>Authentication Error. Try again.</p>';
}
// Once the Netlify Identity widget is loaded, check the user's login state
netlifyIdentity.on('init', user => {
if (!user) {
// If no user, trigger the login modal
netlifyIdentity.on('login', () => {
// After login, reload the page to actually fetch the content
document.location.reload();
});
netlifyIdentity.open();
} else {
// User is logged in, now we can fetch the specific draft content
const urlParams = new URLSearchParams(window.location.search);
const slug = urlParams.get('slug');
// This is the crucial part: redirect to the actual page!
// Your headless CMS config should set the slug field for this to work.
window.location.href = `/posts/${slug}/?preview=true`;
}
});
</script>
</body>
</html>
The Secret Sauce: The “preview” Query Parameter
Did you see that ?preview=true redirect? That’s the key. In your Hugo template for the single post page, you can now check for this parameter.
When ?preview=true is present, you tell Hugo’s getJSON function to bye-pass the cache. It will fetch the latest data from your CMS on every single request, ensuring the editor always sees their very latest, unsaved changes. It’s the dynamic bit in your otherwise static site.
{{/* layouts/posts/single.html */}}
{{ $preview := eq (printf "%v" .Site.Params.preview) "true" }}
{{/* Fetch the data, but if it's a preview request, add a cache-busting timestamp */}}
{{ $contentUrl := .Site.Params.contentApi }}
{{ if $preview }}
{{ $contentUrl = print $contentUrl "?t=" (now.Unix) }}
{{ end }}
{{ with $data := getJSON $contentUrl }}
<h1>{{ .title }}</h1>
<div>{{ .content | markdownify }}</div>
{{ end }}
You’d set the preview parameter in your site config via an environment variable on your preview server so it’s only true there.
# config.toml on your preview server
preview = true
# config.toml on your production server
preview = false
The Inevitable Rough Edges
This is not a perfect simulation. It’s a separate build on a separate branch. If your production site has a million posts and your preview branch only has ten, the “related posts” section will look different. The build time might be faster or slower. It’s a functional preview, not a perfect one. You need to manage your editor’s expectations on this. Tell them, “It shows the content and styling accurately, but some peripheral data might differ from production.” This honesty will save you.
The other big pitfall? CMS webhooks. Your preview branch needs to rebuild when content drafts are saved, not just published content. Most CMSs have a “save draft” webhook trigger. USE IT. Configure your hosting platform (Netlify, Vercel, etc.) to rebuild your preview branch on this webhook. Without this, your preview site will stagnate and editors will lose trust in the system faster than you can say “cache invalidation.”
It’s a bit of setup, but once it’s running, it transforms the content process from a leap of faith into a predictable, professional workflow. And that means fewer 5 PM phone calls. Worth it.