Post preview when using Ghost as a Headless CMS

When using Ghost as a headless CMS, is there a way to setup post/page previews within the admin to show the static site? Or do we completely lose the ability for previews?

Hey there! Previews are definitely a bit more complex for headless sites.

I have a setup that works, and it uses a local dev server + Netlify for the production site.

The URL is configured to the site that’s deployed (https://www.redacted/blog/), so when I click the preview button in the Admin panel, it goes to https://www.redacted/blog/p/{uuid}, which is my production site.

So, I’ve added the following redirect config to my netlify.toml:

  from = "/blog/p/:splat"
  to = "/preview-helper/"
  status = 200 # The preview helper depends on the url to be a preview url

The preview-helper endpoint contains a script which redirects to my local site. Note that I’ve only included the important bits:

	<meta name="robots" value="noindex,nofollow" />
		const redirect = localStorage.getItem('preview_domain');
		const parsedRedirect = redirect ? new URL(redirect) : null;
		if (redirect && !== {
			parsedRedirect.pathname = window.location.pathname;
			window.location.href = parsedRedirect.toString();
		} else {
			window.setRedirectDomain = host => {
				const redirect = new URL(host);
				redirect.pathname = window.location.pathname;
				localStorage.setItem('preview_domain', host);
				window.location.href = redirect.toString();
	<div class="article-warning container">
		<h1 class="gh-header">Post Preview</h1>
		<div class="gh-content">
			<p class="gh-content">Post previews are only available in development environments. Run <code>setRedirectDomain('http://hostname:port');</code> in your console to enable auto-redirects.</p>

Before I talk about my local config, there’s one last thing I do for production - prevent indexing by adding the following to your robots.txt:

User-agent: *
Disallow: /preview-helper/
Disallow: /blog/p/

Ok, now let’s talk about your local setup!

The exact method that you use is going to vary based on your environment, but here’s the important part: the data for /blog/p/{uuid} needs to be pulled in at request time, or the preview experience will be suboptimal.

Here’s the getPost method I have implemented:

	function tokenToJwt(token) {
		const jwt = require('jsonwebtoken');
		const [id, secret] = token.split(':');

		return jwt.sign({}, Buffer.from(secret, 'hex'), {
				keyid: id,
				algorithm: 'HS256',
				expiresIn: '5m',
				audience: `/${API_VERSION}/admin/`

	async function getPost(uuid) {
        // NOTE: in Ghost 5.0, the API_VERSION will no longer be applicable!
		const url = `${process.env.GHOST_API_URL}/ghost/api/${API_VERSION}/admin/posts/?filter=uuid:${uuid}&formats=html`;
		return axios.get(url, {
			headers: {
				authorization: `Ghost ${tokenToJwt(process.env.GHOST_ACCESS_TOKEN)}`
		}).then(response =>[0]);

I use 11ty (0.x) as my SSG, so the solution I went with is this:

  1. I have a file called preview. This is a custom post renderer with the following config:
  data: collections.preview
  size: 1
  alias: post
eleventyExcludeFromCollections: true
permalink: '/{{ post.url }}'

The data for this page is hardcoded with magic strings, and looks like this:

	getDummyData() {
		return [{
			url: '/preview-helper/',
			primary_author: {
				url: '__primary_author_url__'
			published_at: new Date(0),
			updated_at: new Date(0),
			primary_tag: {
				name: '__primary_tag_name__'
			feature_image: '__feature_image__',
			title: '__title__',
			custom_excerpt: '__custom_excerpt__',
			html: '__content__'

NOTE: I’ve only included the data that my theme needs, meaning this is a condensed version of the Ghost API post data.

As part of the magic string substitution, I also remove some styles which hide the post in production (since we want to only show the post in development).

  1. I’ve added some middleware to BrowserSync to handle posts;
	async function middleware(request, response) {
		try {
			if (!process.env.GHOST_ACCESS_TOKEN) {
				return this.fatal(response, this.messages.noAccessToken);

			const postUuid = request.url.replace(/\/$/, '').split('/').pop();
			if (!this.isUuidLike(postUuid)) {
				return this.fatal(response, this.messages.badId(postUuid));

			const message = await this.render(postUuid.toLowerCase());
		} catch (error) {
			if (error.response) {
				this.fatal(response, this.messages.apiError(error.response));
			} else {
				this.fatal(response, this.messages.unknownError(error));

// Snippet of browsersync config:
	server: {
		middleware: [{
			route: '/blog/p',
			handle: middleware
  1. The render function reads the magic-string file and performs replacements. If certain fields are empty, they’re removed in the browser with javascript

Again, this implementation is just one option - with eleventy 2.0, there might be an option to make this serverless, drastically reducing the overhead needed!

Hope this helps :slight_smile: