I moved the blog from being a dynamic instance of Ghost to Gatsby a while back and I want to walk you through the steps I did to make it work.


Setting up a Ghost blog is in itself a journey, especially when it comes to finding a hosting that supports it well. As for myself, I use Opalstack (disclosure: affiliate link): they allow me to have any number of domains and applications with a wide range of different technologies (and with ssh access, I can install anything that doesn't require root access, if needed).

Of course it's still a shared account so I'm responsible for my resources usage, that's why, beyond the obvious performance reasons, I decided to look into staticising my blogs.

The first step was to follow the docs from Ghost, they are quite clear on what needs to be done to have the site up and running.

Gatsby & Netlify

If you are familiar with React, Gatsby is not an unknown beast, changing components and styles was a smooth experience.

For my personal blog, I also went a little further and instead of just dropping the html of the post body as dangerouslySetInnerHTML, I process the htmlAst tree provided by the GraphQL layer with rehype-react and render it with custom components

import React from 'react'
import rehypeReact from 'rehype-react'
// a component that enables footnotes links
import WithNotes from '../components/post/WithNotes'
// a component that detects what to embed if anything
import EmbedOrNot from '../components/post/EmbedOrNot'

const comp = tag => props => <WithNotes component={tag} {...props} />

const renderAst = new rehypeReact({
  Fragment: React.Fragment,
  createElement: React.createElement,
  components: {
    'img-sharp-inline': ImgSharpInline,
    p: comp('p'),
    figcaption: comp('figcaption'),
    blockquote: comp('blockquote'),
    li: comp('li'),
    figure: EmbedOrNot,

const Post = ({ post }) => {
  // [...]
  return (
    // [...]
      <section className="post-content">
    // [...]

Once the template is working fine locally it's time for deploying it.

Netlify documentation is a perfect place to start. Remember to add the ENV variables to the build  GHOST_API_URL, GHOST_CONTENT_API_KEY matching the value you added locally to the .ghost.json file following the Ghost/Gatsby integration docs.

Once deployed, verified it to be working fine on the link provided by Netlify itself, it's time to redirect the domain to it. Once that's done the website is up and running but our work is far from over.

First of all we need to be able to access Ghost to create new content or edit old one. What I did for my setup was to create a subdomain api and direct it to my Ghost instance, which I switched to be a private site, so it won't show if invoked directly, risking to effect SEO score with duplicated content.

By adding these redirect rules to the Netlify _redirects file I managed to get the CMS running on the /ghost subpath of the main domain as you would normally have for Ghost. The redirects file should be in the static folder as it needs to be in the root of the produced bundle for prod, not of the repo.

/content/* https://kapi.cedmax.net/content/:splat 301!
/assets/* https://kapi.cedmax.net/assets/:splat 301!
/private/* https://kapi.cedmax.net/private/:splat 200
/ghost/* https://kapi.cedmax.net/ghost/:splat 200
/p/* https://kapi.cedmax.net/p/:splat 200

There is an obvious drawback in this setup though: the preview, supported by the last line of the redirects file, will load with the old  Ghost theme, which might not contain the extra feature built in Gatsby. And it might not load css/js unless you went back to the root of your api subdomain and unlocked the private site – this is only a mild annoyance, but you know little things that grow under your skin.

I found an interesting tutorial on how to setup previews, but it falls short on the production environment as it relies on a completely static implementation and for security purposes reccomends to not push to production as the Ghost admin keys would be available in the client bundle.

Not good enough for me.


What I ended up doing was following the implementation part of the preview component in Gatsby, as per the aforementioned tutorial, changing a bit the paths to match the Ghost p as opposed to the tutorial preview.

Install the Gasby plugin for client paths

> yarn add gatsby-plugin-create-client-paths

update your gatsby-config.js adding the following:

// ...
module.exports = {
    // ...
    plugins: [
            resolve: `gatsby-plugin-create-client-paths`,
            options: { prefixes: [`/p/*`] },
        // ...

and then create a page p.js page in the appropriate Gatsby folder

import React from 'react'
import PropTypes from 'prop-types'
import { Router } from '@reach/router'
import Post from '../templates/post'

class PreviewPage extends React.Component {
  constructor(props) {
    this.state = {
      post: null,
  async componentDidMount() {
    if (this.props.uuid) {
      const post = await fetch(`/preview?id=${this.props.uuid}`)
        .then(response => response.json())

      if (post) {
        this.setState({ post })
  render() {
    // when ghost answers back
    if (this.state.post !== null) {
      const data = {
        // match the expected structure
        ghostPost: {
          published_at: new Date().toISOString(),

      const location = this.props.location
      return <Post data={data} location={location} />
    return null

PreviewPage.propTypes = {
  uuid: PropTypes.string,

const Preview = () => (
    <PreviewPage path="/p/:uuid" />

export default Preview

The bit of code in the componentDidMount that fetches the data would at this point 404, as it relies on a piece we haven't done yet

Using Netlify functions to fetch drafts

Netlify offers its own flavour of lambdas, called Netlify Functions. We can take advantage of that serverside execution to fetch the drafts from ghost without having to expose our admin credentials.

First we need to add to the netlify.toml – in the root of our project – the following bit under the [build] section (if you followed the tutorial this far there will be already stuff under build, add it as a new property):

  functions = "functions"

then create a file in the following path ./functions/preview/preview.js

this is how it looks like this

const GhostAdminAPI = require('@tryghost/admin-api')

let ghostConfig
try {
  // this is due to the fact that netlify would error at build time
  // if the path was in the require.
  const dynamicLoading = '../../.ghost.json'
  ghostConfig = require(dynamicLoading)
} catch (e) {
  ghostConfig = {
    production: {
      apiUrl: process.env.GHOST_API_URL,
      adminApiKey: process.env.GHOST_ADMIN_API_KEY,

const api = new GhostAdminAPI({
  url: ghostConfig.production.apiUrl,
  key: ghostConfig.production.adminApiKey,
  version: `v3`,

exports.handler = async event => {
  try {
    if (!event.queryStringParameters.id) {
      throw new Error('The post id is missing')

    const browseParams = {
      filter: `uuid:${event.queryStringParameters.id}`,
      formats: 'html',

    const post = (await api.posts.browse(browseParams))[0]

    return {
      statusCode: 200,
      body: JSON.stringify(post),
  } catch (err) {
    return { statusCode: 500, body: err.toString() }

Rember to add the GHOST_ADMIN_API_KEY to your build ENV variables and in the local ./.ghost.json file as adminApiKey. Last but not least

change this line in the _redirects

- /p/* https://kapi.cedmax.net/p/:splat 200
+ /preview /.netlify/functions/preview  200

This should do, now the previews are working. Unless you were working with Ast as I did, in which case you might have to change the preview component componentDidMount to account for the Ast creation:

// rehype is already installed but you might want to make it an explicit dependency
import Rehype from 'rehype'

const rehype = new Rehype().data(`settings`, {
  fragment: true,
  space: 'html',
  emitParseErrors: true,
  verbose: false,

// [...]

  async componentDidMount() {
    if (this.props.uuid) {
      const post = await fetch(`/preview?id=${this.props.uuid}`).then(response =>
      const htmlAst = await rehype().parse(post.html)
      post.childHtmlRehype = { htmlAst }

      if (post) {
        this.setState({ post })

// [...]

And that was it, all the benefits of a dynamic website, with the resilience and the speed of a static one.


To Mattia and Luca for proof reading it, and to Fabio for almost doing it 😂

The Code

cedmax.net: https://github.com/cedmax/cedmax.net

Read Next