How do you use routes from a headless CMS in Nuxt JS (SSR)?
Asked Answered
Z

3

12

The Question

I'm trying to use the routes, as defined by the CMS, to fetch content, which is used to determine which Page to load in my our Nuxt frontend. So, how do you implement your own logic for connecting routes to content, and content to pages while maintaining all of the awesome features of a Nuxt Page?

Any help would be greatly appreciated!

The Context

Headless CMSs are on the rise and we'd like to use ours with Nuxt in Universal mode (SSR).

We are tied into using Umbraco (a free-to-host CMS), which has a flexible routing system that we can't restrict without massive user backlash.

Note: Umbraco is not headless, we're adding that functionality ourselves with the Umbraco Headrest project on GitHub

Note: Each piece of content has a field containing the name of it's content type

For example, the following content structure is valid.

.
home
├── events
|   ├── event-one
|   └── event-two
|       ├── event-special-offer
|       └── some-other-content
├── special-offer
└── other-special-offer

So, if we want to render these special offers with nuxt, we will need to use Pages/SpecialOffer.vue.

The Problem

The problem is that the default paths for these special offers would be:

  • /special-offer
  • /other-special-offer
  • /events/event-two/event-special-offer

The editors can also create custom paths, such as:

  • /holiday2020/special (which could point to event-special-offer)

The editors can also rename content, so other-special-offer could become new-special-offer. Umbraco will then return new-special-offer for both /other-special-offer and /new-special-offer.

Attempted Solutions

The following approaches were promising, but either didn't work or were only a partial success.

  1. Use middleware to forward URL requests to Umbraco and determine the component to load from the result (fail)
  2. Use a plugin to forward URL requests to Umbraco and determine the component to load from the result (fail)
  3. Map the routes from the content (partial)
  4. Use a single page that uses response data to load dynamic components (partial)

1) The middleware approach

We attempted to create an asynchronous call to the CMS's API and then use the response to determine which page to load.

middleware.js

export default function (context) {
  return new Promise((resolve, reject) => {
  /** 
   * Prepend the cms provided url's path with /content,
   * which the API knows is for CMS content
   */
    context.app.$axios.$get(`/content${context.route.path}`)
      .then((content) => {
        // Save the resulting content to vuex
        context.store.commit('umbraco/load', content)

        const { routes } = context.app.router.options

        /**
         * The content has a contentType property which is a string
         * containing the name of the component we want to load
         * 
         * This finds the corresponding route 
         */
        const wantedRoute = routes.find(x => x.name === content.contentType)

        // Sets the component to load as /Pages/[componentName].vue
        context.route.matched[0].components = {
          default: wantedRoute.component
        }

        resolve()
      })
  })

Issues

When running on the server, middleware seems to run after the route is resolved, leading to the following error:

vue.runtime.esm.js?2b0e:619 [Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.

Note: on first page visit, middleware only runs on the server side and on subsequent visits, middleware only runs on the client side.

2) The Plugin Approach

As plugins run earlier in the lifecycle, we thought we'd try the same approach there and try to call the CMS's API and determine the component to load based off of the result.

plugin.js

await context.app.router.beforeEach( async ( to, from, next ) => {
  /** 
   * Prepend the cms provided url's path with /content,
   * which the API knows is for CMS content
   */
  const content = await context.app.$axios.$get( `/content${context.route.path}` );
  
  // Save the resulting content to vuex
  context.store.commit('cms/load', content);

  /**
   * The content has a contentType property which is a string
   * containing the name of the component we want to load
   * 
   * This finds the corresponding route 
   */
  const routes = context.app.router.options.routes;
  const targetRoute = routes.find( r => r.name === content.contentType );

  // Sets the component to load as /Pages/[componentName].vue
  to.matched[0].components = {
    default: targetRoute.component
  };

  next();
})

Note: Plugins run on both the server and the client on the first page visit. On subsequent visits, they only run on the client.

Issues

Unfortunately this didn't work either as app.router.beforeEach() does not allow for asynchronous code. As such, the API request never resolved.

3) The Route Mapping Approach

Generating a list of route maps worked fine unless you changed the routes after the build. The issue here is that you can't update the routes at runtime, so renaming or adding new content in the CMS would not be mirrored in Nuxt.

4) The Dynamic Component Approach

This approach seems to be the default solution to our problem, and involves using a single Page in Nuxt that calls the API and then dynamically loads the component. Unfortunately, this approach removes the majority of Nuxt's page specific features (such as page transitions).

Losing these features is really unfortunate, as Nuxt is REALLY nice and we'd like to use it as much as possible!

Nuxt JS GitHub Issues

  • I raised the failing async issue as this bug on the nuxt.js GitHub
  • I commented on the this GitHub issue which is a feature request for the same problem but has some good dialogue around the issue
Zelazny answered 11/6, 2020 at 11:22 Comment(2)
This is late response, but checkout prismic ( headless CMS), you can define routes with their link-resolver or route-resolver and you can use it along i18n.Lions
Wondering what you ended up doing. I'm facing a similar issue but have to support two languages on top of that :(Importunate
A
2

What we have tried is use one universal page template to handle all possible routes, code can be found in: https://github.com/dpc-sdp/ripple/blob/v1.23.1/packages/ripple-nuxt-tide/lib/module.js#L208

That single page template will match * routes user asks for. However, you can define multiple templates for multiple routes rules if you like.

The communication between Nuxt app and CMS API is like below:

Nuxt: Hi CMS, user want to the data of path /about-us.

CMS: Got it. Let me check the CMS database, and find the same url. If there is one, I will send the data back to you, otherwise I will give you a 404.

That way you won't lose page transition features. In that Nuxt page template, you can then send request to CMS and get the page data. This can be done in Nuxt router middleware or page async data, now maybe fetch hook is the best place. I am not sure which is the best, we are using async data which may not is the best practice.

Adhamh answered 7/8, 2021 at 8:48 Comment(1)
Thanks for your response and example, it's really appreciated! It looks like your solution is the standard Dynamic Content approach. While this is perfectly valid, it isn't quite what we were looking for. In the end, we opted to make our CMS completely headless and take all routing away from it. Giving us the all-natural Nuxt experience.Zelazny
L
2

After a lot of research and dead-ends, I've finally found a way to achieve this with Nuxt 3.

The plugin runs on every page load so it'll always be in sync with the CMS and also uses the default pages folder, so everything should work as normal.

The only downside: You have an initial API request before loading any data into the page. There might be a way to pass data through the route so that we can get away with only one API request but I haven't looked into that yet.

export default defineNuxtPlugin(async (nuxtApp) => {
   
   const config = useRuntimeConfig();
   const route = useRoute();
   const router = useRouter()
   
   // Get current page info and template from CMS

   const { data } = await axios.get(config.public.BASE_API_URL+'page?path='+route.path)
   
   // Remove all predefined routes to prevent conflicts
   
   router.getRoutes().forEach((route, i)=>{
      router.removeRoute(route.name)
   })
   
   // Add current page to routes
   
   if(data && data.url) {
      
      router.addRoute({
         name: data.id,
         path: route.path,
         component: () => import(`../pages/${data.template}.vue`)
      })
      
   }
   
})

EDIT

I would not recommend using the previous method as the router will not transition to other pages and will instead reload the whole app.

The solution I ended up using was a mix of built-in dynamic routes from Nuxt for my different post types (as all posts of a certain type will use the same template) and dynamically creating routes for all other pages (where the user can select the associated template in the CMS). The final code looks like this:

export default defineNuxtPlugin(async () => {
   
   const router = useRouter()
   const config = useRuntimeConfig()
   
   // Get all page urls + post types with associated templates from Wordpress
   
   const { data } = await useApi("vl/routes");
   
   // Remove all predefined routes
   
   router.getRoutes().forEach((route, i)=>{
      router.removeRoute(route.name)
   })
   
   // Create routes for each post types
   
   data.value.post_types.forEach(item => {
      router.addRoute({
         path: `${item.uri}/:slug`,
         component: () => import(`../pages/${item.template}.vue`)
      })
   });
   
   // Create routes for pages
   
   data.value.pages.forEach(item => {
      
      const uri = item.uri ?? '/'
      const template = item.template ?? '404'
      
      router.addRoute({
         path: uri,
         component: () => import(`../pages/${template}.vue`)
      })
      
   })
   
})

With my custom route returning data looking something like this

{
   "post_types": [
      {
         "uri": "/article",
         "template": "article"
      },
      {
         "uri": "/project",
         "template": "project"
      }
   ],
   "pages": [
      {
         "uri": "/",
         "template": "home"
      },
      {
         "uri": "/about",
         "template": "about"
      }
   ]
}
Liverwort answered 29/8, 2022 at 15:9 Comment(0)
P
1

You can use extendRoutes to build out the routes on site deployment.

nuxt.config.js

// Router middleware
router: {
    async extendRoutes (routes, resolve) {
        // pulls in routes from external file
        await customRoutes(routes, resolve)
    }
},

Then something like this:

// extendRoutes snippet
let pageRoutes = require('./routes-bkp.json')

try {
  const { data: { pages } } = await getPagesForRoutes.queryCMS()
  pageRoutes = pages
  console.log('Route Request Succeeded.')
} catch {
  console.log('Route Request Succeeded.!  Using backup version.')
}

pageRoutes.forEach(({ slug }) => {
  routes.unshift({
    name: slug,
    path: `/:page(${slug})`,
    component: '~/pages/_page'
  })
})

In your function. You will then need a build trigger in Umbraco (not sure if this is possible).

Polyclitus answered 22/12, 2021 at 11:29 Comment(1)
Doesn't this have a major flaw ? As soon as a user creates a new page or edits an existing url, the back-end and front-end are no longer in sync until the site rebuilt ?Liverwort

© 2022 - 2024 — McMap. All rights reserved.