Modeling Navigation in a Headless CMS (Strapi/React) - Part 2

Following on from Part 1, this instalment of the post will show how you can generate nested slugs in Strapi by using the relationship fields to parent/child pages and the Page model's lifecycle hooks.

Generating a full slug for each page will allow us to mimic the url structure typically found in a traditional CMS and will more accurately reflect the hierarchical nature of our content, particularly when you want your pages slugs closely resemble your navigational levels.

Since we already set up the parent_page relationship on our page model in Part 1 of the post, we're almost ready to go with defining the nesting of our pages. If you skipped that part, I suggest going back to recap how I set it up, but essentially the page collection type needs a relationship field added to it parent_page which is a has one relationship with the page type.

These relationships exist on each page as each individual instance of a page may only have a single parent page (as each page needs a single path to it to generate the full slug), but the page may have many child pages (and thus is the parent page for those child pages).

One more field we'll need on the Page collection type is a text field to house our full slug, which will be generated for each page on save/update based on the full slug of the parent page, or their own slug if they have no parent page assigned to them.

In Strapi, click Content-Types Builder and select our Page type.
Select Add another field and choose a Text field and give it the name of something like full_slug - click Finish and then Save

To make things easier to understand, we'll delete all of the dummy pages we created in Part 1 so that our full slug generation will work from the start. You don't have to delete them all if you don't want to, but after we enter our full slug generation code in the Page model, you'll have to go and resave each page individually again to create the initial full slug so that any child pages can use it to generate their own full slug. If you don't do this and create a child page and save it, you'll see that when it tries to prepend the parent's full slug, it will be undefined.

Now all of our pages are gone, we need to add some code which will take care of populating our newly-created full_slug field. We'll refactor our original implementation to get rid of some duplicate code and to add a few more guards against specific edge cases. There is a bit going on here but we'll break it down with comments and an explanation below:

const slugify = require("slugify");

// Our function to query for the parent page
// and generate the full slug for the current page
const generateFullSlug = async (id, slug) => {
  const parentPage = await strapi.query("page").findOne({ id: id });

  return parentPage.full_slug + "/" + slug;

// Function to accept current page data and
// manipulate values before saving/updating
const sanitizeData = async (data) => {
  // If the title has changed and there is no slug,
  // generate a new slug based on the title
  // otherwise if the slug has been manually changed
  // we slugify it to remove unfriendly characters
  if (data.title && !data.slug) {
    data.slug = slugify(data.title, {
      lower: true,
  } else if (data.slug) {
    data.slug = slugify(data.slug, {
      lower: true,
  // Initially the full slug for any given page
  // will be the page slug itself - if the page
  // has been assigned a parent page, we
  // query for the parent page's full slug and
  // prepend it to the current page slug to
  // create the current page's full slug
  let fullSlug = data.slug;
  if (data.parent_page) {
    fullSlug = await generateFullSlug(data.parent_page, data.slug);

  // Set the page's slug to whatever we 
  // determined above
  data.full_slug = fullSlug;

  return data;

module.exports = {
  lifecycles: {
    beforeCreate: async (data) => {
      // On initial creation, we only need to sanitze
      // the data as it's not possible for this page to
      // be the parent page of another page just yet
      data = await sanitizeData(data);
    beforeUpdate: async (params, data) => {
      // Check that this update function wasn't invoked
      // by out afterUpdate function below - if so, let the
      // full slug be set without any more processing
      if (data.updateChild) return;

      // Check that the user didn't select the current page
      // to be it's own parent, which would cause an infinite
      // loop in slug resolution
      if (parseInt(params.id) === data.parent_page) data.parent_page = null;

      data = await sanitizeData(data);
    afterUpdate: async (params, data) => {
      // If we've updated a page, check to see if it
      // is the parent page of any other pages - if so
      // we need to update the children's full slugs
      // in case the parent's full slug has changed.
      // We pass "updateChildren" because the
      // strapi.query.update function will cause the beforeUpdate
      // function to run for each child page so we need to tell
      // it to skip the other logic for a regular page update
      const children = await strapi
        .find({ parent_page: params.id });

      if (children.length) {
        children.map(async (page) => {
            { id: page.id },
              updateChild: true,
              full_slug: params.full_slug + "/" + page.slug,

To start, we've abstracted some of the duplicate code we were using in the beforeCreate and beforeUpdate lifecycle hooks and created a new function sanitizeData - this function just accepts the page model object as a parameter and takes care of creating our basic page slug if it isn't already set or if the title/slug has changed. After that, we have a new piece of logic that initialises a fullSlug variable which is initially set to be the basic page slug. We do this because not every page will have a parent page, so the full slug will just be the regular page slug.

If a page does have a parent page set, we then call another abstracted function called generateFullSlug - this queries for the parent page data and returns the concatenation of the parent page's full slug, plus the slug of the current page being saved. We then set the page object full_slug to be whatever the fullSlug variable ends up with - either the basic slug, or the generated slug prepending the parent's slug. The sanitizeData function then passes back the manipulated data, ready for the lifecycle hook to use to save to the database.

The beforeCreate function is simple and only involves sanitizing the data. The beforeUpdate is a bit more interesting, as we have a couple of initial checks to see what kind of update it is - if the data object contains a property updateChild we return immediately to skip the rest of the function's logic - the reason for this will become apparent shortly. The next check before manipulating the data is around ensuring that the user didn't try to assign a parent_page relationship to itself, which would cause a cyclical reference structure and infinite loop, so we check the ids and if they match, we strip that out. The function then does the normal data manipulation.

We have a new lifecycle method called afterUpdate, and this function is responsible for updating the children of a particular page. This method checks to see if the page that was just updated has any child pages, as in it has been assigned to be the parent page of any other pages. It it does have children, it then loops through and updates the slug of each one, passing the new full slug and a flag to let beforeUpdate know to skip the rest of the logic as we're just updating the full_slug and no other fields. This ensures that the paths to the children remain consistent in the case of the parent page's slug being changed.

For example, let's say we have 3 pages - 1 is the root or parent page which has another page as a direct child, and that child also has another child:


  • title: Parent Page

  • slug: parent-page

  • full_slug: parent-page

Child (Level 2)

  • title: Child 2

  • slug: child-2

  • full_slug: parent-page/child-2

Child (Level 3)

  • title: Child 3

  • slug: child-3

  • full_slug: parent-page/child-2/child-3

If we were to omit changing child page slugs when a parent page's slug changes, we'd end up with a hierarchy that's a bit out of whack:

Parent - Updated

  • title: New Parent Title

  • slug: new-parent-title

  • full_slug: new-parent-title

Child (Level 2)

  • title: Child 2

  • slug: child-2

  • full_slug: parent-page/child-2

Child (Level 3)

  • title: Child 3

  • slug: child-3

  • full_slug: parent-page/child-2/child-3

This can be problematic, especially if you're using the page slugs to generate breadcrumbs to allow people to navigate through the nest pages back to parent pages.

In saying all of the above, there are certain performance implications with using a function like this - if you have a parent page that has tens or even hundreds of nested child pages, updating the root page will cause a lot of database updates each time you save your page, so you really need to decide if it's worth the trade off. For most basic sites though, it should be fine and you shouldn't notice too much of a performance hit on save.

Now when you fetch your menu items in your React application, you can use the full_slug property on the related page fields to create the links to your pages. This means you can have multi-level menus that more accurately reflect your content structure, or they can also be used to provide the paths for your pages when being statically generated by frameworks like Gatsby.js or Next.js.

Hopefully this gives you some ideas about how you can approach creating your page navigation and slugs for your next website or app.

Hi, I'm Wayne.

I am a developer at Linktree who is passionate about learning new technologies across all levels of the stack. This website acts as an outlet for me to share my learnings, as well as provide ideation of solutions to the problems we face while trying to deliver products for the modern web and beyond.

Feel free to reach out if you have any requests, critiques, comments, suggestions, or even if you just want to say "Hey!" via the linked social channels or email below.

See-ya amongst the 1's and 0's!

© Wayne Lincoln 2023