nuxt / nuxt

The Intuitive Vue Framework.
https://nuxt.com
MIT License
53.06k stars 4.87k forks source link

Ability to add pages dynamically based on an API call #27643

Open CernyMatej opened 2 weeks ago

CernyMatej commented 2 weeks ago

Describe the feature

What is this for?

Many developers are unsure about the best approach to implement something like this in Nuxt. Here are the main concerns:

——

@danielroe has stated multiple times that all of this is already possible (in multiple ways), so this may just be a matter of mentioning this use case in the documentation (which I would happily do once we figure out everything)

I'm linking the original issue here: https://github.com/nuxt/nuxt/issues/7437

Additional information

Final checks

CernyMatej commented 2 weeks ago

I managed to solve most of the points brought up in the issue, but sadly, not all of them.

Since I wanted to preserve Nuxt routing features for these dynamic pages, having a single […slug].vue catch-all file with a v-if on the page components wasn't the way to go. I also didn't want Nuxt to generate routes for these pages, so I stored them outside the pages directory.

I came to the conclusion that a plugin would probably be the best solution, so I wrote the following to directly customize the Vue Router:

export default defineNuxtPlugin({
    name: 'routing',
    parallel: true,
    setup: (nuxtApp) => {
        const router = useRouter()
        const appConfig = useAppConfig()

        // the currently known dynamic routes
        const pages = useStatePages()

        router.beforeEach(async (to) => {
            const isRouteInRouter = router.getRoutes().some(route => route.path === to.path)
            let page = pages.value.get(to.path)

            // if the page is not a dynamic page, continue to Nuxt routing
            if (page === null) return

            // if we don't have the page data, fetch it
            if (page === undefined) {
                try {
                    page = await PAGE_TYPE_API_CALL(to.path)
                } catch (e) {
                    // continue to Nuxt routing
                    return
                }

                pages.value.set(to.path, page)
            }

            // add the route to the router if it's not already there
            let route = null
            if (!isRouteInRouter) {
                const pageTypeName = page?.type

                // if the page type is not known, continue to Nuxt routing
                if (!pageTypeName) return   // should go to 404 or something

                route = {
                    path: to.path,
                    name: `dynamic${to.path.replace(/\//g, '-')}`,
                    component: () => import((`../pages-dynamic/${pageTypeName}.vue`)),
                }
                router.addRoute(route)
            }

            return route ? { name: route.name, query: to.query } : undefined
        })
    },
})

This has been working mostly fine. However, I did have to make some workarounds, which I am hoping we can get rid of, and there is also one significant limitation with this implementation that I still wasn't able to overcome.

  1. I would like to have this plugin in a parent Nuxt Layer. However, I couldn't figure out how to use an alias in the dynamic import so that the path is resolved relative to the currently running project and not the parent layer.
  2. The API request in the Vue Router's beforeEach callback runs only after Nuxt plugins. This unnecessarily increases the initial response time since the “page type” API call usually doesn't need to depend on anything else and could run in parallel (with plugins).
  3. Vue Router floods the console with warnings when it encounters a router link to one of the dynamic pages, so I was forced to create an empty […slug].vue file, where I just throw a 404 createError. I'd love to hear ideas about a better solution, but this has been working well enough so far.
Rigo-m commented 2 weeks ago

First improvement on this topic would be to move the plugin logic to a build time module for performance, stability and versioning reasons

MorevM commented 2 weeks ago

@Rigo-m

This functionality cannot be build-time, in the use case of Nuxt over CMS it is assumed that the user can create new entities in the admin panel, and their creation should not require rebuilding the project. Or the user can change routing settings, rename entities and so on.

@CernyMatej @danielroe

I solved the problem at the application level rather than at the plugin level (and I think that's the right thing to do). I've created a single catch-all page (and this is the only page in /pages directory) that makes a request to the API to define the page, then passes further control to it.

Simplified:

// pages/[..path].vue

<template>
  <component :is="pageComponent" />
</template>

<script setup>
  const entries = {
    'page-index': defineAsyncComponent(() => import('~pages/page-index/page-index.vue')),
    'page-catalog': defineAsyncComponent(() => import('~pages/page-catalog/page-catalog.vue')),
  };

  const { data } = await useAsyncData('entry', () => fetchPageComponent(useRoute().path));

  const pageComponent = computed(() => entries[data.value.entry]);
</script>

Next, each page component requests data for itself. This was not so easy with Nuxt 2 and the "async data only at page level" restriction (although I figured out a way to solve it anyway), but with Nuxt 3 and Suspense this restriction is gone and it works fine.

I don't think this can be universally solved at the platform level by the way, the solution is going to be roughly the same as above on the user side anyway.

I'd rather have a built-in ability to disable file routing, as the solution suggested above works but feels a little hacky with that single catch-all route (most of time).

Actually, from the documentation, it looks like Nuxt already allows you to do something like the solution above via single app.vue, but the comment "If not present, Nuxt won't include vue-router dependency" stops me from doing it. I still need vue-router, although I don't need file routing using the pages directory.

If we can make two new explicit settings in the core like { useFileRouting: boolean; includeVueRouter: boolean } and remove the implicit dependency of /pages directory from vue-router, then the approach above is perfectly fine with me (although I'm not the author of the issue, but I think we're solving the same problem).

app.vue will be a single entrypoint of the application (which doesn't feel like a hack), and it's up to the developer to decide where to pass control from there.

danielroe commented 2 weeks ago

At runtime, you can customise the routes used to initialise Vue router with a router options file: https://nuxt.com/docs/guide/recipes/custom-routing#using-approuteroptions. That would enable you to override the file based routing entirely based on runtime conditions.

I'm looking to make this accept an asynchronous function for edge cases.

CernyMatej commented 2 weeks ago

@MorevM If I'm not mistaken, your implementation still won't be able to run in parallel with plugins, would it? It would be: await all plugins -> await page type fetch in the catchall route -> await data of the individual pages

Also, have you figured out a way to make the dynamic imports relative to the currently running Nuxt project? (So that this logic can live in a nuxt module / layer)

@danielroe Since you liked the reply suggesting moving the logic to a build-time module, would you mind explaining how that would be relevant in this use case, please? 🙏 How would that improve performance? We don't know the routes at build-time, and as already mentioned, they can change at any time during runtime.

MorevM commented 2 weeks ago

@CernyMatej

I've never had asynchronous plugins, but probably yes, it doesn't run early. The rendering path you described looks right. Yes, it produces an additional delay on page detection, meaning there will always be at least two consecutive requests on the page. Where this is critical, I use caching the entire page, and cache invalidation is done on the backend side.

I don't fully understand the proposed approach with dynamically adding routes to vue-router, and can't do research right now, but in general it looks like it doesn't solve the problem.


I can't strictly state that if I opened the page /about/ and initially associated page-about with it, that this will not change. How to invalidate routes?

I can't even guarantee that the same path will be associated with the same page component for different users or for the same user over time (there may be different roles, different authorization states, for example, /account/ could be page-account for an authorized user and page-auth for another based on the session variable).

It is clear that these checks can be done in different ways and this dynamic is not the “correct” or “best” approach, but from a mental model POV it is the simplest, and I have projects where this approach is used.

I mean, when I open a page, I always have to check what the backend thinks about it. I'm not sure if this can be done by adding/modifying routes to the vue-router.

Also, I like the proposed single entrypoint approach because defining the page component is only one of its tasks, there is actually a lot more going on there (initializing shared stores and states, trackers, global watchers, analytics, and so on)

CernyMatej commented 2 weeks ago

I use async plugins to for user auth & cart initialization on an e-commerce website, so I'd like to figure out a way to get rid of the unnecessary delay, if possible. My implementation utilizing vue router doesn't solve this either.

You are correct that modifying the routes once added to the router can be quite cumbersome… It depends on the use case - I actually prefer that the routes get “cached” and I don't need to make the API call again as long as the user doesn't do a hard refresh.

I hope we can find a general solution, which ideally eliminates all the issues and workaround-like things.

MorevM commented 2 weeks ago

@CernyMatej

Well, as long as the state of the cart/user is not dependent on a specific page, and the same URL always refers to the same page component, you can initialize all this not in plugins*, but at the application level along with the page definition, this is optimal in terms of performance.

* of course, if you have access to modify the solutions used, if some external plugin does not provide another initialization option, this will not solve the problem. I'm just free to choose solutions and implementations. :)

This is what I use when possible, simplified:

// pages/[..path].vue

await useAsyncData('init', () => {
  return Promise.all([
    // Get the actual page component (always while navigating)
    useEntryStore().init(),
    // Initialization of all other things (only initially, on the first load)
    isServer() && useCartStore().init(),
    isServer() && useUserStore().init(),
    // ...whatever else
  ]);
});

My stores act as services here. Caching of “route -> page” link in this approach, if necessary, occurs in the service itself, so if there is a cache, this request is not made at all, and we do only one request per page (directly from the page component). If there is no cache, then there will always be at least two blocking requests, by design.

In fact, I’m happy with everything about this approach (in Nuxt 3, Nuxt 2 is more complicated), except for having the entrypoint in a strange place pages/[..path].vue, this does not sufficiently reflect its role in the overall architecture.