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
:
[[redirects]]
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" />
<script>
const redirect = localStorage.getItem('preview_domain');
const parsedRedirect = redirect ? new URL(redirect) : null;
if (redirect && window.location.host !== parsedRedirect.host) {
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();
}
}
</script>
<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>
</div>
</div>
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 => response.data.posts[0]);
}
I use 11ty (0.x) as my SSG, so the solution I went with is this:
- I have a file called
preview
. This is a custom post renderer with the following config:
---
pagination:
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).
- 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());
response.write(message);
response.end();
} 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
}]
},
- 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