Building a Feature-Rich CMS with Open Source Software
Formidable recently created a new visual brand and in the process, we rewrote our website almost from scratch. In addition to expressing our new brand, we wanted to build a site that would be easy to maintain, both for people writing code and for people updating the site via a CMS.
Formidable.com is a content-driven website, and most of that content is authored via a CMS. In the past year, more than 700 contributions were made to our site by CMS users.
Given its heavy use, we wanted our CMS to be a powerful and ergonomic tool that empowered its users to do more with confidence. We also wanted the CMS to be easy for developers to maintain and support. Most of our architectural and design decisions were made with these goals in mind.
Choosing a Headless CMS
We knew that friction and frustration could occur when developer experience and CMS user experience diverged, so we tried to keep the two workflows as similar as possible. We decided on a git-based CMS so that data generated via the CMS would be stored in GitHub, and follow the same PR-based release workflow as changes trigged by developers. This would give us full control over the data generated via the CMS, and make it much easier for developers and CMS users to collaborate.
Among git-based, headless CMS solutions, Netlify CMS is a clear stand out. Netlify CMS is a free, open-source CMS built in React. It supports custom UI widgets and previews and is designed to be extended. At four years old, Netlify CMS is a relatively mature project for the React ecosystem, but development has been extremely active over the past year, resulting in a long list of attractive new features.
Choosing a Static Site Generator
We knew we wanted to create a static site for performance reasons, and given our depth of experience with React, we quickly narrowed our options to popular React-based static site generators: Gatsby and React Static.
Both tools generate progressive static sites, producing both static html and optimized JS bundles for every page. For users, this means both the fast initial page load of a static site, and instantaneous navigation of a single page app. For developers, this means a static site that can be written as a normal React app with very few gotchas. The main difference between Gatsby and React Static is how each tool handles data. Gatsby requires the use of GraphQL for querying data, and expects data queries to be colocated with the components responsible for rendering that data. In contrast, React Static places no requirements on how data is sourced or transformed. Rather than colocating data logic with rendering logic, React Static expects each route to be provided with the data it needs. Prioritizing CMS user experience led us to choose React Static over Gatsby, because separate data fetching, data transformation and component rendering made it much easier to reuse front end code to enhance our CMS.
Putting it all together
Using these tools, we created a feature-rich CMS that allows users to create new pages from scratch and preview their changes in real time using the same components that render our live site. Best of all, we did it with entirely free, open source software.
We keep the source for our website private, but the tools we used and the patterns we developed in building it are worth sharing. What follows is a deep dive into the architecture of our new site with a focus on CMS integration.
Design and Data
The new site is based around a set of modular content blocks. Content blocks are designed to stack in a single column, and may be stacked in any order. Each block is responsive, and their relative positions don't change on smaller screen sizes. Designing around a flexible, single-column layout greatly simplified the options we provided in our CMS admin panel. The content blocks that define our home page have been annotated in the image below:
To represent a set of content blocks in Netlify CMS, we used a list widget with variable types. This new feature allowed us to define a set of all the possible content blocks a page might use, each with fields specific to that content block. The code below shows a truncated example of the CMS config for our home page:
const homePageConfig = { name: "home", label: "Home", file: "src/content/home.md", fields: [ // page metadata { name: "title", widget: "string" }, { name: "description", widget: "string" }, { name: "contentBlocks", widget: "list", types: [ { name: "pageIntro", widget: "object", fields: [ { name: "title", widget: "string" }, { name: "description", widget: "markdown" } ] }, { name: "blogPostGrid", widget: "object", fields: [ { name: "featuredPosts", widget: "list", fields: [{ name: "post", widget: "relation", collection: "blog-posts", searchFields: ["title"], valueField: "title", displayFields: ["title"] }] } ] } ...
With this config, a CMS user is able to add, re-order, and edit information within each content block. The resulting data is stored as a markdown file with YAML frontmatter. The data that defines our home page looks approximately like this:
--- title: Formidable description: >- Formidable is a Seattle, Denver, Phoenix, and London-based engineering consultancy... contentBlocks: - type: pageIntro title: We Are Formidable description: >- #### The JavaScript Consultancy Trusted By Engineers Formidable is a global design and engineering consultancy specializing in React.js, React Native, GraphQL, Node.js, and the extended JavaScript ecosystem... ... - type: sectionHeadline content: Recent Blog Posts link: /blog linkText: View All - type: blogPostGrid featuredPosts: - post: introducing-the-new-formidable-brand - post: urql-devtools-the-road-to-v1 - post: the-many-benefits-of-good-pull-request-descriptions ... ---
The data defining the set of content blocks for the home page is stored in the contentBlocks
array, where each element is an object with a type
property that determines which content block component to render.
Notice that some of the data is incomplete for the content block it will render. Specifically, featuredPosts
only includes the title for each post
while the BlogPostGrid
component expects each post to include a description, date, author image, and more. Netlify CMS leaves it up to us to handle these kinds of data relationships. The flexibility React Static enables in terms of data handling lets us create a set of simple transformations that we could use to format data for both our static routes and our live CMS previews.
React Static
React Static uses a config file to define every route the static site will contain, the data it will receive, and the template it will render. This is where we handle fetching and transforming the data we provide to each route. The code below is a simplified config file that describes some of the patterns we are using.
// static.config.js { paths: { ... }, plugins: [ "react-static-plugin-react-router", "react-static-plugin-styled-components" ], getRoutes: async () => { // Get all markdown page data (content blocks for the home page, etc) const pageData = await getPageData(); // Get all markdown collection data (blog posts, authors etc.) const collectionData = await getCollectionData(); /* sharedData is a collection of metadata that is used to add related data to content blocks. */ const sharedData = extractMetaData(collectionData); return [ { path: "/", template: "src/templates/home", /* addSharedData returns a modified copy of pageData with extra data from sharedData added to any content block that needs it */ getData: () => addSharedData(pageData.home, sharedData) }, { path: "/cms-admin", template: "src/cms/index", // sharedData is provided to the cms-admin route getData: () => ({ sharedData }) } ... ]; } };
We start by fetching our pageData
(content blocks, etc. for pages like the home page) and collectionData
(blog posts, case studies, etc). Collection metadata is stored in a sharedData
object, which our addSharedData
method uses to add the extra information that content blocks like BlogPostGrid
need to render. For most routes we add shared data at build time so that each route only receives the data it needs. For the CMS admin route, however, we provide the entire sharedData
object to enable live previews. React Static's automatic code and data splitting lets us provide large amounts of data to the CMS route without worrying about performance impacts on other pages.
React Static's config file also defines the components each route will render. Since data manipulation is handled separately, most of our page components are extremely simple. For example, our home page does little more than render an array of content blocks. Notice that we export the Home
component both with and without route data added.
import { withRouteData } from "react-static"; ... // export the component without route data for use in the CMS export const Home = props => { const { title, description, contentBlocks } = props; return ( <BasePage title={title} description={description}> <section> <ContentBlocks blocks={contentBlocks} /> </section> </BasePage> ); }; // export the default component with route data for use in the static site export default withRouteData(Home);
Netlify CMS
Because React Static is a progressive static site generator, we are able to add the entry point for our CMS admin app as a regular React Static route. The component our CMS admin route renders is responsible for initializing the CMS, registering custom preview templates, and rendering a node for the CMS to mount to. We're using netlify-cms-app
, a version of Netlify CMS that is intended to be manually initialized and does not include its own version of React. A simplified version of our CMS admin component is shown below:
import CMS from "netlify-cms-app"; import { withRouteData } from "react-static"; import { Home } from "../templates/home"; ... class NetlifyCMS extends React.Component { componentDidMount() { // register a custom preview for the home page CMS.registerPreviewTemplate( "home", previewProps => { /* See https://www.netlifycms.org/docs/customization/ for the full list of props Netlify CMS provides to preview components */ const { entry, getAsset } = previewProps; const data = entry.get("data").toJS(); /* addSharedData is the same transformation used to add related data to home page content blocks. sharedData is supplied as route data to the CMS admin route */ const dataProps = addSharedData(data, this.props.sharedData); return ( <PreviewWrapper getAsset={getAsset}> {/* Home is the same page component we render for the acutal site */} <Home {...dataProps} /> </PreviewWrapper> ); } ) ); CMS.init({ config }); } render() { return <div id="nc-root" />; } } /* Because the entry point for the CMS admin app is a regular React Static route we are able to provide route data just as we would to any other route */ export default withRouteData(NetlifyCMS);
Previews for the CMS admin app use the same components and data transformations that are used for the live site. Because neither piece is concerned with where data comes from, we only need to make sure that the preview data we receive from Netlify CMS is in the same format as the data we fetch and parse from markdown files.
A bit of extra work is required to support styled-components
styles and previews for uploaded images. We encapsulated this logic in a PreviewWrapper
.
import { StyleSheetManager, ThemeProvider } from "styled-components"; ... class PreviewWrapper extends React.Component { componentDidMount() { /* See https://github.com/netlify/netlify-cms/issues/1408 for additional style injection strategies */ if (document) { const iframe = document.getElementsByTagName("iframe")[0]; this.iframeRef = iframe.contentDocument.head; } } render() { return this.iframeRef ? ( <StyleSheetManager target={iframeRef}> <ThemeProvider theme={theme}> {/* getAsset resolves image paths for uploaded images in localStorage. We consume AssetContext in our Image component to enable previews. */} <AssetContext.Provider value={this.props.getAsset}> <Header /> {this.props.children} <Footer /> </AssetContext.Provider> </ThemeProvider> </StyleSheetManager> ) : null; } }
All of the development work on our CMS admin app was made tremendously easier by the new netlify-cms-proxy-server
which allows developers to use Netlify CMS with a local git repository instead of a live GitHub repo. This addition not only made our CMS easier to maintain, it also made the CMS something developers often prefer to use when editing content!
Integrating Netlify CMS into our new site required us to think about the needs of the CMS user at every step of the development process and greatly informed our architectural decisions. The effort has already paid off by improving both developer and CMS user workflows and by bringing these two workflows closer together. Our finished CMS lets users preview their changes in real time using the exact same components a developer would see when running a local server. CMS content changes result in GitHub pull requests, so CMS users also benefit from quality checks and staging previews. And when things don't look quite right on staging, PR review and collaboration with developers is frictionless.
There have never been so many compelling headless CMS options, but personally, I'm thrilled by what we were able to accomplish with free, open-source tools!