Custom NetlifyCMS previews with Next.js

NetlifyCMS and Next.js make a really nice combination. More often than not, the admin view provided by the CMS is enough. However, sometimes we might need custom preview components. In this post we will see how to do this with NetlifyCMS and Next.js

NetlifyCMS and Next.js

Have you ever worked with NetlifyCMS? It is actually a pretty cool CMS and I like to use it for simple cases where no processing on the server side is required. One thing I like in particular about it, is its Admin interface. The interface is super simple to work with, not as bloated as many other admin interfaces and even allows to view a preview of the content you're editing. As long as the content is all in markdown, everything works out of the box.

Where we need custom previews

However, when the content we want to preview isn't markdown based anymore, we're out of luck with the provided solution and need to use custom previews. Adding custom previews is theoretically a very simple task and there are tons of examples for multiple frameworks. Yet, I couldn't find one for Next.js based applications.

However, with the help of other examples, especially gatsby based ones, I managed to get custom admin previews in Next.js to work.

In the following I'll describe how I created a preview that, in my case, is able to add a Leaflet map, displaying a GPX track. The gpx file URL is an attribute I have on my blog posts.

How to add custom NetlifyCMS previews to a Next.js application

If you started out with any of the typical NetlifyCMS examples for Next.js, you will almost certainly have the following files in your project:

/public
    /admin
        /index.html
        /config.yml

config.yml is your CMS configuration file and you're probably rather familiar with it, as it's the main way to configure your cms and set your collection types. index.html is the file that is responsible for displaying the admin page of NetlifyCMS. To this end, this file loads the netlify cms application via a script tag.

Now, since we want to use our own components, and therefore include the admin page in our build process, we won't need this file anymore. We can just delete it via rm public/admin/index.html. Instead we are going to use an npm package for this, installed by npm i netlify-cms-app.

Now, before we create a page which uses this package, let's move config.yml one level up into the root of the public folder.

The resulting folder structure is:

/public
    /config.yml

On to the next.js page that displays the admin. In this example we'll put it under /pages/admin.js. Of course, you're free to use whichever path you fancy.

This file is responsible for initializing the netlify cms app, therefore the content we put here is:

import { useEffect } from 'react'
import BlogPostPreview from '../previews/BlogPostPreview'

const Admin = () => {
  useEffect(() => {
    ;(async () => {
      const CMS = (await import('netlify-cms-app')).default
      CMS.init()
    })()
  }, [])

  return <div />
}

export default Admin

By using an effect which imports the app, we avoid problems with server-side-rendering, since netlify-cms-app needs a window instance.

Now if we go to our application's /admin page, we should be able to see the admin interface, just like we did before with the index.html file in our public folder. Now, however, it is served by a Next.js page and not as a static file, which happens to be an html file anymore.

NetlifyCMS admin page in browser

Great! Now we can begin to hook our blog posts up to a BlogPostPreview component that we created. The preview component I needed looks like the following code snipped. Yours will most likely not need the workaround effect I used to load and parse a GPX file:

import React, { useEffect, useState } from 'react'
import BlogPost from '../components/BlogPost'
import { BlogPostData } from '../lib/blog-posts'
import { markdownToHtml } from '../lib/markdown-processor'
import { loadGpx } from '../lib/parseGpx'

interface BlogPostPreviewProps {}

const BlogPostPreview = ({ entry, widgetFor }) => {
  const title = entry.getIn(['data', 'title'])
  // etc.
  const gpxUrl = entry.getIn(['data', 'gpxUrl'])
  // ...

  const post = {
    attributes: {
      title,
      gpxUrl,
    },
    html: content,
    relatedArticles: [],
  } as BlogPostData

  // workaround in my case
  // to load and parse the gpx file
  // typically not required
  useEffect(() => {
    ;(async () => {
      if (gpxUrl) {
        const gpxData = await loadGpx(gpxUrl)
        setGpxData(gpxData)
      }
    })()
  }, [gpxUrl])

  return <BlogPost post={{ ...post, gpxData }} />
}
export default BlogPostPreview

Netlify's documentation on these components is quite good, but in short: our previews receive data via the entry property, which we can query like we do in the example above. This causes the title and gpxUrl properties from the left pane on the admin panel, to be passed on to the preview component. Which in turn then wraps the actual BlogPost component that I use throughout my app.

Now the final and missing step is to hook the component up to our CMS instance:

import { useEffect } from 'react'
import BlogPostPreview from '../previews/BlogPostPreview'

const Admin = () => {
  useEffect(() => {
    ;(async () => {
      const CMS = (await import('netlify-cms-app')).default
      const cloudinary = (await import('netlify-cms-media-library-cloudinary'))
        .default
      CMS.init()
      // hook our preview up to the cms
      CMS.registerPreviewTemplate('blog', BlogPostPreview)
    })()
  }, [])

  return <div />
}

export default Admin

Aaaand... we're done!

Basically you can now create an arbitrary amount of preview components for all kinds of collections. There might be a few cases where using preview components is challenging, if your app relies a lot on data that gets generated during build time, which is the case when I use Next.js as a static site generator.

gotchas

If using layout across your app via _app.js, you might need to dynamically remove it for the admin page. simple solution:

import Head from 'next/head'
import Layout from '../components/Layout'
import { useRouter } from 'next/router'
import React from 'react'

export default function App({ Component, pageProps }) {
  const router = useRouter()

  const LayoutToUse = router.asPath.startsWith('/admin')
    ? React.Fragment
    : Layout

  return (
    <>
      <Head>
        <meta name="viewport" content="initial-scale=1.0, width=device-width" />
      </Head>
      <LayoutToUse>
        <Component {...pageProps} />
      </LayoutToUse>
    </>
  )
}

If you're using a media provider like cloudinary, you need to install the library and setup in admin:

npm i netlify-cms-media-library-cloudinary

import { useEffect } from 'react'
import BlogPostPreview from '../previews/BlogPostPreview'

const Admin = () => {
  useEffect(() => {
    ;(async () => {
      const CMS = (await import('netlify-cms-app')).default
      const cloudinary = (await import('netlify-cms-media-library-cloudinary'))
        .default // this
      CMS.registerMediaLibrary(cloudinary) // this
      CMS.init()
      CMS.registerPreviewTemplate('blog', BlogPostPreview)
    })()
  }, [])

  return <div />
}

export default Admin