Aysha Anggraini

On code, design, and movement

Adding live previews with WordPress, Gatsby, and Netlify

I wrote about this topic briefly when I changed my blog to a hybrid of WordPress and GatsbyJS. My initial solution involves using JWT to query WordPress preview posts. The solution was quite simple:

  • Install JWT Authentication for WP-API on WordPress
  • Create a preview page with a login form that will return a JWT token through this endpoint: /wp-json/jwt-auth/v1/token
  • Once login succeed, the preview page should query specific post revision through this endpoint: /wp-json/wp/v2/posts/postId
  • Given that my WordPress and blog are hosted by different providers, I need to hijack the preview button in order for the preview to be redirect to my blog. I had created a simple WordPress plugin for this.

While this works well, I was not satisfied with the solution given that I needed to login twice just to preview a content. Other downside to this method includes:

  • Insecure handling of JWT token given that I had stored it in a cookie that is accessible through the client side. This means that it is prone to XSS attack.
  • Unnecessary hijacking of WP preview button. This may not be future proof if WordPress is updated and the internal mechanism of preview button changes.

Knowing what I know today, I would have approached this solution in an entirely different manner if only I had the patience. But I just really wanted live previews to work for me to start right away. It has been 3 years; I got wiser and more patient… hopefully. I’ve decided to explore a better solution that doesn’t involve signing in twice and adding any extra plugins.

The main challenge

By far, the hardest part about this entire thing is figuring out a way to add live previews without compromising security. I had researched several articles about how to safely store JWT tokens on the client side. Hasura wrote an amazing guide on how to handle JWT on the frontend and while the idea presented is not a difficult concept to do, I am reluctant to tinker more with WordPress than I needed to. I just want a preview functionality and I want the easiest way to build that.

I have also seen several articles that suggest the use of wpnonce and including the token in the URL but these ideas don’t really work for me. Using nonces didn’t seem to work in querying the post revisions data while the idea of putting a token in a URL is also risky.

So what did I ended up doing?

In short, it’s a combination of cookies and serverless functions:

  • Store the JWT token as a hardened cookie on my blog after I logged in to WordPress using the admin_init hook.
  • Enable Netlify functions on my blog. This serverless function will get the cookie from the request header and query the post preview data.
  • Use GatsbyJS SSR API to consume the Netlify functions. It will pass the cookie to the function via the request header and render the preview page.

My solution involves adding the JWT and refresh token as HTTPOnly cookies to my main blog. Taking a page from Hasura’s recommendation, this would be a hardened cookie (HTTPOnly, Secure, and SameSite: Strict) and it will be added on WP’s admin_init action hook. So whenever I logged in to WordPress and view the admin page, the token is set as a cookie immediately. This cookie will be accessible on WordPress and the main site.

It’s important to note that this solution will only work if your WordPress instance and site is hosted under the same domain. Example of a possible setup would be a WordPress instance under a subdomain like cms.site.com while the main site is hosted under site.com. If your site is not on the same domain, it is still possible to go with a serverless solution but it won’t be as straight forward as the method described here. It will just be like getting data from a third-party API where authentication is necessary but the experience can be simplified through Single Sign-On.

Once the token is available as a cookie, the main site will be able to access it through server side request. This request will be done using Netlify functions. Then, it will use the token to get the post revisions data.

I’ll be sharing my detailed solutions below. Note that code can change in a few months time so do have a look at the official documentation for these tools as well. At the time of writing, I am working with GatsbyJS 5.2.0.

#1: Install essential plugins for WordPress

The most important plugins to install are the WPGraphQL and WPGraphQL JWT Authentication. The configuration for these plugins are straightforward.

#2: Generate JWT token for authenticated users on WordPress

The following code will check if the cookie doesn’t exist and if the user is logged in and it will run when the WP admin area is shown. We would set the token as a cookie only if these conditions are met. I set the expiry of the cookie to the same one as the expiry date of the token for a much more predictable flow.

function set_cookie_on_main_blog() {
  if ( is_user_logged_in() && !isset( $_COOKIE['jwt_token'] ) ) {
    $auth = new \WPGraphQL\JWT_Authentication\Auth;
    $authToken = $auth->get_token(wp_get_current_user());
    $expiry = time() + (60 * 60);
    $arr_cookie_options = array (
                'expires' => $expiry,
                'path' => '/',
                'domain' => 'yourdomainhere.com',
                'secure' => true,     
                'httponly' => true, 
                'samesite' => 'Strict'
    );
    setcookie('jwt_token', $authToken, $arr_cookie_options);
  }
}
add_action( 'admin_init', 'set_cookie_on_main_blog', 10, 2 );

This code can be added as a plugin or in the functions.php file.

#3: Configure Netlify functions for our GatsbyJS site

This step is the most frustrating for me as there isn’t any updated documentation on setting up Netlify functions with GatsbyJS. I am not sure if GatsbyJS is losing the market share on popularity for JAMStack tools but there isn’t much articles written on this topic lately. The most recent guide I can find on this was written in 2021 and it doesn’t work properly if you follow it exactly as instructed. The plugins involved have gone through several updates since then and configurations have changed.

Here’s how to configure Netlify functions and GatsbyJS for development and production by the end of 2022 (and probably would last until the first quarter of 2023):

Install netlify-cli and gatsby-plugin-netlify

These plugins are essential for working with functions in local development mode. Some articles would suggest you to install @netlify/plugin-gatsby manually but I suggest to not install that as netlify-cli will install and load that plugin automatically if you specify it in your netlify.toml file. In my experience, installing it separately will cause a conflicting peer dependency error which involves a package called @gatsbyjs/reach-router.

So, to be specific, do not run npm install @netlify/plugin-gatsby. Specify it under the toml file instead. Install netlify-cli and gatsby-plugin-netlify first and add the following configurations in order to enable functions.

Configure netlify.toml:

[[plugins]]
package = "@netlify/plugin-gatsby"

This will automatically add the plugin without the need to install it manually.

Add gatsby-plugin-netlify in gatsby-config.js:

// In your gatsby-config.js
plugins: [`gatsby-plugin-netlify`]

Your gatsby-config.js may contain more configurations than this.

Set up Netlify functions on GatsbyJS

Create a folder at the root of the project called .netlify/functions. By default, Netlify will look for this folder in order to run functions. You can use a different naming conventions by specifying the folder names under Netlify’s functions settings.

Add a file called preview.js under this folder and add the code below. This is the format of Netlify functions:

exports.handler = async function (event, context) {
  return {
     statusCode: 200,
     body: "Hello world!"
  }
}

This will show “Hello World” when you visit yoursite.com/.netlify/functions/preview

You can run the function by starting up the server through netlify dev command. Visit the link to the function and you should be able to see the response.

Once this part is done, we can start working on querying the preview data.

#4: Query preview data using Netlify functions

Modify the code above to query the preview data. This is how I’ve updated my functions. Note that I have also included two modules to make it easier for me to query the preview post data: cookie and graphql-request.

var cookie = require("cookie")
var gql = require("graphql-request").gql
var GraphQLClient = require("graphql-request").GraphQLClient

exports.handler = async function (event, context) {
  // get the token through a cookie
  // we will pass the cookie through the headers in our request
  // using Gatsby SSR api later
  var cookies = cookie.parse(event.headers && event.headers.cookie)
  var authToken = cookies["jwt_token"]

  // if token is not defined, we return a forbidden code
  if (!authToken) {
    return {
      statusCode: 403,
    }
  }

  // Use the token in the request here
  const graphQLClient = new GraphQLClient(
    GRAPHQL_ENDPOINT_HERE,
    {
      headers: {
        authorization: "Bearer " + authToken,
      },
    }
  )

  // get the post ID from the API's URL here
  // post ID will be requested through URL's query params
  const body = event.queryStringParameters
  const postId = body && body.postId

  // return an error if the postId is not provided
  if (!postId) {
    return {
      statusCode: 400,
      message: "There is no postId provided",
    }
  }

  // GQL query to get the preview data
  const query = gql`
    {
      postBy(postId: ${postId}) {
        revisions {
          edges {
            node {
              date
              uri
              id
              title
              content
            }
          }
        }
      }
    }
  `

  // simple function that returns the preview post or an error if it fails
  async function attemptToGetRevisions() {
    try {
      return await graphQLClient.request(query)
    } catch (e) {
      return {
        statusCode: 500,
        message: "Internal server error",
      }
    }
  }

  const results = await attemptToGetRevisions()

  // give an error if API query fails for any reason
  if (results.statusCode === 500) {
    return {
      statusCode: 500,
      body: results.message,
    }
  }

  return {
    statusCode: 200,
    body: JSON.stringify({
      response: results.postBy.revisions.edges[0], // return the most recent revisions
    }),
  }
}

This code will get the token from the cookie via the request header. It will use the token to get the revisions post data using the GraphQL endpoint. This endpoint is added by the WPGraphQL plugin.

#5: Render the preview page using CSR or GatsbyJS SSR API

GatsbyJS provides an API called getServerData to allow us to pre-render pages using server side rendering. Note that you don’t need to use SSR since you can certainly consume the Netlify functions using client side request. With CSR, you don’t need to pass the cookie at all as a specific portion of the above code has taken care of this:

exports.handler = async function (event, context) {
  // get the token through a cookie
  // headers will be populated automatically if this function is called on the client side
  var cookies = cookie.parse(event.headers && event.headers.cookie)
  var authToken = cookies["jwt_token"]
}

For client-side request, you won’t need to pass token at all since the token is available in a HTTPONLY cookie. This is useful since our token stays hidden even when the request is made through the client-side.

However, for this example, we will be looking at rendering preview page using GatsbyJS SSR API.

In order to enable SSR on the preview page, simply add getServerData function underneath the component. This is what I have under my pages/preview.js file:

// expose query and headers in order to provide the correct request context
export async function getServerData({ query, headers }) {
  // get the post ID from the url
  // preview page URL will look like this: /preview?p=789
  const pageId = query.p || query.preview_id

  const res = await fetch(
    `https://yoursite.com/.netlify/functions/preview?` +
      new URLSearchParams({
        postId: pageId,
      }),
    { headers } // Add the request headers in order to pass the cookie to the Netlify functions
  )

  const result = await res.json()

  return {
    props: result?.response?.node,
  }
}

Consume the netlify functions by providing request headers and post ID.

const Preview = ({ serverData }) => (
  <>
    {serverData?.content && (
      <div>
        <h1>
          <span dangerouslySetInnerHTML={{ __html: serverData?.title }} />
        </h1>
        <div dangerouslySetInnerHTML={{ __html: serverData?.content }}
        />
      </div>
    )}
  </>
)

export default Preview

export async function getServerData({ query, headers }) {
  const pageId = query.p || query.preview_id

  const res = await fetch(
    `https://yoursite.com/.netlify/functions/preview?` +
      new URLSearchParams({
        postId: pageId,
      }),
    { headers }
  )

  const result = await res.json()

  return {
    props: result?.response?.node,
  }
}

The complete code for the pages/preview.js file.

Now, if you visit the preview page with the token stored as a cookie and the post ID included in the preview URL, it should render the preview post as expected. Once this is deployed to production, Netlify will automatically create a new function called __ssr in order to handle the server side requests for this page.

Error handling when query fails

Under this Gatsby SSR setup, a 500 status code will be returned immediately if the cookie or the post ID is not provided. This is useful if anyone tries to visit the preview page without any authentication. In order to handle this, I created a 500.js page to handle the error message in an elegant manner. Otherwise, it will just show a 500 error on a blank white page.

I tried to handle this in a different way by returning a 404 status code under getServerData when the cookie or post ID is not available. However, Gatsby or Netlify doesn’t seem to respect the status code. It keeps on returning 200 even though the request made to the function failed. Therefore, it’ll continue to render an empty preview page.

I am not sure if this is Gatsby or Netlify’s fault. It might be Gatsby as I saw a comment about it but it doesn’t seem to be fixed. It could also be gatsby-plugin-netlify as adding the status code seems to override the redirect file. This file will return the page with a 200 status code even if the API call failed.

Therefore, I decided to forgo adding a different status code and just let the API do its own thing.

Future improvements

As it stands, I am quite happy with the preview functionality implemented on this site. However, there are further improvements that can be made for a better experience.

As you can see, there’s an expiry to the JWT token and it is short lived for security reasons. When the token expires, the cookie will disappear and preview data query will fail. I will have to go back to the WP admin page for a new token and cookie to be generated.

We can improve this experience by storing a JWT refresh token in the cookie as well. When our access token expires, we can get a new one using the refresh token. The refresh token should have a longer life span compared to the access token. Therefore, we can use it to query for new tokens whenever we need to. A rough outline of the process will look like this:

  • Edit the functions.php to store the refresh token in the cookie. We can use $auth->get_refresh_token (based on the plugin source code). The cookie should have an expiry as well and it will be helpful if it corresponds to the refresh token’s actual expiry time.
  • Under the functions code, we can check if jwt_token is available. If it is not, we can attempt to use the refresh token in order to get a new access token. This can be done by making a GraphQL request to the endpoint.
  • Store and use the new token to query and render post revisions data.

This improvement may not be important if you’re the only contributor to the site. But for a blog that is edited by multiple people, it will be good to have this feature for a more predictable and uninterrupted workflow.