Modeling Navigation in a Headless CMS (Strapi/React) - Part 1
The JAM (Javascript / API's / Markdown) stack has been gaining huge traction in the last couple of years and has almost become the de-facto standard for many hobbyists, startups, and even enterprise-scale businesses looking to dip their toes into one of the hottest, modern development trends.
Decoupling content from whatever framework or library you're using to render your frontend comes with it's own set of challenges, particular for those (like myself) who cut their teeth in more traditional content management systems (CMS) like Wordpress, Joomla and Drupal.
This post is the first of 2 parts and looks at one solution I've been using to achieve relatively traditional navigation management in a headless CMS to provide flexibility on the frontend without sacrificing the ease-of-use for the end-user (and potentially the person paying for your services). The first part will look at setting up a basic slug system in Strapi and then using it to create a main menu which will be consumed by a simple React frontend. The second part will focus on generating nested slugs and adding additional levels to the menu.
At Hello Again, we have recently completed a few projects that rely on headless CMS's as a data source for statically generated marketing sites, as well as web apps with static generation/server rendering capabilities. Our technologies of choice include Contentful, which is a PaaS (Platform-as-a-Service) CMS provider with a generous free plan and a sleek, clean interface for defining your models and content. Another tool we use is Strapi, which is an open-source, Node based CMS that you can host yourself and extend to meet any requirements your project may have.
One of the biggest challenges we face in any headless CMS is deciding on how to model the navigation for the site so that it is easy to consume in React and generate the markup for the menus, but be intuitive enough for the end-user so that they are able to add new items or levels of items to the navigation areas.
In a traditional CMS such as Wordpress, menu items can link to pages, archives, individual posts, custom post types and more using a slug to identify the piece of content it links to.
Typically, if a "child" page in a website is assigned a "parent" page, the child will inherit the parent's slug and that will prepended to it's own slug to represent the hierarchical nature of the resources and to indicate that the page content or context may have a relationship.
The user can then assign that piece of content to a menu and place it at the top-level or nested as many levels deep as they like, and the slug to the page will remain the same. The structure of the menu itself doesn't have to represent the hierarchy of the content, as a pice of content may be linked to from multiple menus and in multiple levels. It's not uncommon to see top-level menu items that link to a page that is 3 levels deep content-wise, and then have that same content appear as a nested menu item in another menu, such as the footer or sitemap.
In the headless CMS world, content doesn't conform to this type of structure out of the box, as unlike traditional systems, the pieces of content have no real opinion about where they should live or how they should be accessed. You generate your own content models using the primitive data fields the headless CMS offers, and then it's completely up to you to decide where and how to create relationships between pieces of content. Due to this, some of the headless options don't even have a typical "slug" field - Contentful has a primitive data type for generating a slug automatically from the title of a piece of content on initial save, but in Strapi you have to roll-your-own function in the content model's lifecycle hooks to generate slugs for content after saving or updating.
Let's have a look at how you could model a basic page and slug system in Strapi. I won't detail the process of getting Strapi up and running on your local environment, but a guide to do exactly using the Strapi CLI can be found here.
Once you're up and going with Strapi, created an admin user and are running it in develop mode (so that you can create content types) and have access to the admin panel - the first cab off the rank is to create our Page collection type - this type will define our individual page slugs, as well as form relationships to other pages via parent-page and child-pages relationships.
On the left hand menu, under Plugins select Content-Types Builder
Click Create new collection type
Give the new type a Display name of Page and click Continue
Add a new Text field to your type and call it title - after this, click on the ADVANCED SETTINGS tab and check the checkbox Required field then click Add another field
Add a another Text field to your type called slug and then click Add another field
Add a another Text field to your type called full_slug and then click Finish
Click Save at the top of the page and the development server will restart
At this point we have an extremely basic (and useless) Page model, but we gotta start somewhere right? We had to quickly save our model so that the database tables can be created and our CMS knows the type exists so that we can edit our Page type to include the relationship fields we need. Once the server has finished restarting:
*** Technically you could skip adding this relationship right now as it won't be used until Part 2 ***
Click Add another field
Select a Relation field this time - ensure the right hand type is Page and change the left hand field name to parent and make sure it is a has one relationship then click Add another field
We now have our parent page relationship set up for our Page type - a single page can have one parent page, but may also have multiple child pages. These relationships will come important later when we set up custom lifecycle hooks to populate the full_slug field we defined earlier.
The next step is to make our slug fields autogenerate when a page is saved or updated. First things first: we'll ensure that each page will generate a slug based on the title. When you saved the Page type the first time, under the hood Strapi generated a bunch of folders and files to represent the type:
./api/page/config/routes.json - definitions of CRUD endpoints to access the type
./api/page/controllers/page.js - allows for adding custom business logic to endpoint requests
./api/page/models/page.js - file for hooking into model lifecycle events to manipulate data - we'll be using this for our slugs
./api/page/models/page.json - model settings file defining our fields and other attributes of the type
./api/page/services/page.js - abstracts business logic from controllers into reusable functions
The file we're mainly interested in right now is models/page.js as it will allow us to hook into the beforeSave and beforeUpdate lifecycle events. Essentially, every time a content editor saves or updates a Page, we want to check if the slug field is empty and if so, take the required title field and "slugify" it to generate the slug. We could write our own function to do this, but there are many edge cases that can arise when trying to form a url-friendly slug, so we'll stand on the shoulders of giants and use the slugify package to do the heavy lifting for us.
You'll first need to stop your development server and then install it using your package manager of choice:
$ npm install slugify --save
or $ yarn add slugify
Once that's installed, fire back up your development server, and open the page.js file located in api/page/models and add the following code:
const slugify = require("slugify");
// Strips out special characters from the title to make it url-friendly
function createSlug(title) {
return slugify(title, { lower: true });
}
module.exports = {
lifecycles: {
beforeCreate: async (data) => {
// If so slug is manually set, create it based
// on the required title field, otherwise pass
// through the slug to re-slugify in case the user
// added invalid characters that need to be stripped
if (!data.slug) {
data.slug = createSlug(data.title);
} else {
data.slug = createSlug(data.slug);
}
},
beforeUpdate: async (params, data) => {
// On every update, we also need to check that the user
// didn't clear the slug - if so, regenerate it, otherwise strip
// any invalid characters
if (!data.slug) {
data.slug = createSlug(data.title);
} else {
data.slug = createSlug(data.slug);
}
}
}
}
Now when we create a new page, you only need to enter a page title and click Save and the lifecycle functions will generate a slug. This will only happen if it detects that no slug has been set, as it's entirely possible the user might want to specify a slug manually and in this case we just pass the slug to the slugify function to ensure it removes any invalid characters.
Strapi by default also gives collection types a "draft" system so that content can be authored without being made public until it's ready to be published - so click Publish to make our page fetch-able. The ability to draft/publish content can be configured on the settings for the collection type, so you can disable it if you'd like the Save button to automatically publish every page.
Now that we have a basic slug system in place, we can move onto creating our navigation. For a menu, we can leverage Strapi's concept of a Single Type - this type of model is useful for any content that would only ever need a single instance to be created, such as a main menu or site settings. In this guide, we're only going to create the main menu, but this concept applies in exactly the same way to any other menu, like a footer or sidebar.
Go back to the Content-Types Builder and select Create a new single type
Enter a Display name of Main Menu and select Continue
There are multiple ways we could tackle modelling the individual menu items - for instance, a menu item could be it's own Collection type and we could create it as a has many relationship on the menu so that each item can be independently queryable, but when I initially tried that approach and it did work, there are some limitations in Strapi right now when it comes to the ordering of relationships and the rearranging of items not persisting on save. Due to that, we'll set up a new Repeatable Component so that we can add as many menu items as we like and still have the ability to adjust the order.
Select a Component field and ensure Create a new component is selected and give it a name of items
You'll also need to assign it to a component category, so simple enter menu and press enter or select Create "menu" below the input
Select an icon to represent the component so just choose whatever you feel best represents a menu item (this part doesn't really matter) - click Configure the component
Under name, enter something like menu_item - this will be the name of the component itself, and Strapi won't allow spaces.
By default, Single component will be selected, but we want to change this to Repeatable component so that we can add as many items as we like to our menu. Select that and then click Add first field to the component
Our simple menu items will have 2 fields: a title which will act as the text for the item in the menu itself, and a page, which will be the relation to an individual page - this is how we'll give our menu items the path they link to as we can use the linked page's slug.
Select Text field and give it a name of title and click Add another field
Choose a Relation field and in the right hand box dropdown, choose Page and ensure the relationship is set to has one and click Finish
Save the Main Menu single type and the server will restart
We can now start to make our menu! Firstly, to make things a bit more interesting, it would be a good idea to go and create a few more Pages so that we actually have something for our menu items to link to, so go ahead and make a few of them (don't forget to save and publish if you have the draft system enabled) and then click on Main Menu under the Single Types option in our sidebar navigation.
Click Add new entry and choose the text you want to display for your menu item, and then choose the page that the menu item will link to. Repeat this step until you've built your menu structure and then click Save
We now have a main menu that we can query for and use in our site! Next up, we'll need a frontend to display the menu - for this, I'm going to be using React but you can use any library or framework or language you like, as long as you're able to query the Strapi api endpoints for the data. Like the Strapi setup, I won't run through setting up a frontend but you can follow this guide to setting up a new React app using create-react-app.
Before we run our React app, we first need to install a package to handle the client-side routing in our app when someone clicks on a menu item. There are a few great options out there for this, but in this example we'll use react-router.
$ npm install react-router-dom --save
With react-router installed, it's time to get React up and running:
$ npm start
Now, let's look at setting up our root App component to fetch the menu items.
import React, { useState, useEffect } from "react";
import "./App.css";
function App() {
const [items, setItems] = useState([]);
useEffect(() => {
const fetchItems = async () => {
const res = await fetch("http://localhost:1337/main-menu");
const data = await res.json();
if (data.items) {
setItems(data.items);
}
};
fetchItems();
}, []);
return (
<div className="App">
</div>
);
}
export default App;
Here we're using the useState hook to store our menu items, and is set to an initial state of an empty array. We also have an asynchronous function fetchItems that is called on initial component render by our useEffect hook. We're using the fetch api to perform a GET request for our Main Menu from the Strapi backend by calling the Strapi server url plus the uuid to get the single type we created.
Now we need to create a component for our menu, as well as a component that will act as our page component.
Create a components folder in the src directory of your React project, and within that create a file MainMenu.js with the following code:
import React from "react";
const styles = {
list: {
listStyle: "none",
display: "flex",
},
listItem: {
margin: "0 5px",
},
};
const MainMenu = ({ items }) => {
return (
<nav>
<ul style={styles.list}>
{items.map((item) => (
<li key={item.id} style={styles.listItem}>
<a href={item.page.slug}>{item.title}</a>
</li>
))}
</ul>
</nav>
);
};
export default MainMenu;
The menu component just receives the list of items as a prop that we'll pass through from our App component. It then maps through the array of items and outputs a regular link element with the link pointing to the item's related page slug, as well as the item's title.
Next we're going to need a generic page component, which will ultimately be responsible for fetching the page data for each of our menu item routes, but for now will be a simple placeholder.
Create a new component in the components folder called Page.js and add the following code:
import React from "react";
const Page = () => {
return (
<div>
<h1>Page component</h1>
</div>
);
};
export default Page;
Now we need to add the menu and page components into our app component, along with our routing setup, so switch to the App.js file in the src directory and clear out most of it. Import our components into the file and place it within the App component. Import all of the required components from react-router-dom as well:
import React, { useState, useEffect } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import MainMenu from "./components/MainMenu";
import Page from "./components/Page";
import "./App.css";
function App() {
const [items, setItems] = useState([]);
useEffect(() => {
const fetchItems = async () => {
const res = await fetch("http://localhost:1337/main-menu");
const data = await res.json();
if (data.items) {
setItems(data.items);
}
};
fetchItems();
}, []);
return (
<Router>
<div className="App">
<MainMenu items={items} />
<Switch>
<Route path="/:slug">
<Page />
</Route>
</Switch>
</div>
</Router>
);
}
export default App;
The app component is now set up with a Router to wrap the whole app, meaning we can use react-router's Link component in our menu to enable client-side routing. We're passing the fetched menu items to the MainMenu component, and we've also set up a router switch that has the Route component inside it which is set to use url parameters as it's path - this is so that we can use the slug the menu items link to as a unique identifier for us to query our pages in Strapi and fetch the page data we need to display per-route. We just have a couple more tweaks to make in our MainMenu and Page components before we're really ready to roll.
Back in MainMenu.js, import the Link component and change our standard a tag links to use the Link component, and change the href attribute to "to", as per react-router's api.
import React from "react";
import { Link } from "react-router-dom";
const styles = {
list: {
listStyle: "none",
display: "flex",
},
listItem: {
margin: "0 5px",
},
};
const MainMenu = ({ items }) => {
return (
<nav>
<ul style={styles.list}>
{items.map((item) => (
<li key={item.id} style={styles.listItem}>
<Link to={item.page.slug}>{item.title}</Link>
</li>
))}
</ul>
</nav>
);
};
export default MainMenu;
In Page.js, we need to use the slug as the unique identifier to query for our page data, so import the hooks we need from React, as well as the useParams hook from react-router so we can extract the slug of the current path. We'll also show a loading piece of text temporarily while the useEffect hook is running and fetching our page data.
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
const Page = () => {
const { slug } = useParams();
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`http://localhost:1337/pages?slug=${slug}`);
const data = await res.json();
if (data.length) {
setData(data[0]);
}
};
fetchData();
}, [slug]);
if (!data) return "Loading...";
return (
<div>
<h1>{data.title}</h1>
</div>
);
};
export default Page;
Great, now if we save everything and go to our running React app, we'll see... nothing. Don't worry, we haven't done anything wrong - there is just one more thing we need to adjust in Strapi. By default, all types you create cannot be publicly accessed until you assign permissions to the desired user roles. In the case of our menu and page types, we want anybody to be able to see them, so we need to make sure our frontend (which would have a role of Public - the default role for anyone/anything not authenticated) has the correct permissions.
Back in Strapi, click on Settings and then Roles under User & Permissions Plugin
Select the Public role and underneath the Permissions section, select find under MAIN-MENU, as well as find and findone under PAGE. Click Save
Back to the browser we go - refresh and voila! Our menu should be showing at the top of the page, and if you click any of the links, they'll route to the page's slug we set up in Strapi and fetch and show the page title in the Page component content.
From here you can flesh our the Page types in Strapi to have more interesting content such as using Dynamic Zones to use repeatable Components to display anything you desire.
Obviously there are a lot of glaring omissions, edge cases, loading/error states and optimisations that can be done, and the final result is far from impressive but hopefully it gives you some idea of at least one approach you can use to fetch data and routes dynamically from a headless CMS such as Strapi.
Part 2 of the series (coming soon) will examine adding multiple levels to the navigation and more interesting and dynamic content structure.
A repo with the basic, example code is available here: https://github.com/wayne-lincoln/strapi-react-headless-navigation
If you have any feedback on this post, feel free to drop me a line using any of the channels at the bottom of the site - I'm still in the process of trying to find my blogging voice and I'd love to know what you liked, didn't like, any errors I've made or topics I've glanced over and I'll be sure to take it into consideration for revision of Part 1, Part 2 and beyond.
Happy coding!