A Deep Dive Into How Nuxt Route Transitions Work

Have you ever wondered how route transitions with `NuxtLink` and `NuxtPage` work? Maybe not, but that’s where I found myself today. So I did a deep dive into the Nuxt source code so I could understand it all. Now that I’ve gone down the rabbit hole and come back up again, I thought I’d share with you what I learned.

Michael Thiessen
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view course

The NuxtLink component is a drop-in replacement for your regular run-of-the-mill anchor tag: a.

The docs (and me every other time I’ve talked about it) say that it’s a wrapper for Vue Router’s RouterLink component. This component is itself a wrapper around the anchor tag, and hooks into Vue Router.

However, that explanation leaves out all of the extra stuff that Nuxt does to hook the NuxtLink component into the NuxtPage system, and all of the prefetching logic that goes on as well.

One of the main benefits of using NuxtLink is that it will prefetch pages, so the route change is faster (sometimes instant).

Nuxt will prefetch a few different things based on the target route (the route that the NuxtLink is pointing to):

  1. The Vue components of the target route
  2. Any prerendered payloads of static data for the target route (these are the _payload.json files you may have seen before)
  3. Layouts and middleware used by the route

You can replicate this prefetch behaviour without using NuxtLink by using the preloadRouteComponents composable.

If you want to preload components manually, you can use the preloadComponents composable to do that. You just have to make sure that the components you want to preload are global components (by putting them inside of ~/components/global or naming them with the *.global.vue suffix.):

preloadComponents(['VideoPlayer', 'SurveyQuestion']

Since this whole prefetching system is based on routes, we also need to investigate NuxtPage, since that is the component that renders out whichever route we’re currently on.

Rendering the current route with NuxtPage (and Suspense)

When you use NuxtPage to render a page, Nuxt is using Vue’s Suspense feature (still experimental! all these years later…) internally. This is a simplified sketch of what’s going on:

<template>
  <RouterView v-slot="{ Component, route }">
    <Transition v-bind="resolvedPageTransition">
      <Suspense>
        <component :is="layoutFor(route)">
          <KeepAlive v-if="shouldKeepAlive(route)">
            <!-- This is where your page is rendered -->
            <component :is="Component" :key="pageKey(route)" />
          </KeepAlive>
          <!-- This is where your page is rendered -->
          <component v-else :is="Component" :key="pageKey(route)" />
        </component>
      </Suspense>
    </Transition>
  </RouterView>
</template>

It’s a lot of nested components all doing lots of work! You can read the full source code here.

In order, we have:

  1. RouterView: to render out the currently active route from Vue Router
  2. Transition: so that we can transition between different routes and pages using Vue (transitions using the View Transitions API are handled elsewhere)
  3. Suspense: I’ll keep you in suspense since I’ll go into more detail on this soon
  4. KeepAlive: so that we can keep the page cached in memory instead of unmounting it fully when we go to a new route

For awhile I’ve known that Nuxt keeps the current route rendered until the next route is fully resolved, but it’s taken me a long time to figure out exactly how that works. You may have noticed this yourself. If you use lots of top-level async code in your components, it actually prevents the whole route from switching:

<script setup>
// This will block the component until we've fetched our users
const { data } = await useFetch('/api/users')
</script>

Instead, you can keep your page component synchronous, and then use loading states (for example, via the status value returned from the data fetching composables) so you aren’t blocking the route from switching:

<script setup>
// Instead, we can use the `status` to show a loading state
const { data, status } = useFetch('/api/users')
</script>

All of Nuxt’s data fetching composables can be used either asynchronously or synchronously, through a bit of Javascript magic that I’ve written about previously.

I’ve known that this is Nuxt does when switching routes, but today I finally figured out how it does it. And it involves using Suspense.

Why Suspense is used in NuxtPage

Suspense lets you write asynchronous code inside of your components, and have a “fallback” render while it’s running. You can also nest Suspense components within each other, and they all will have a single fallback spot.

This example is pulled straight from the docs:

<Suspense>
└─ <Dashboard>
   ├─ <Profile>
   │  └─ <FriendStatus> (component with async setup())
   └─ <Content>
      ├─ <ActivityFeed> (async component)
      └─ <Stats>        (async component)

Here, the Dashboard component won’t be rendered until all three async components are fully resolved. This means that the components are fetched (if they’re async components), and the setup() functions have completed and fully resolved as well.

Until then, the Suspense component will render a fallback, which is where we’d put our loading state. The actual code would look something like this:

<template>
  <Suspense>
    <!-- Only render this once all descendents are resolved -->
    <Dashboard />
    
    <!-- Render the loading state in the meantime -->
    <template #fallback>
      Loading...
    </template>
  </Suspense>
</template>

This makes it really easy to manage loading state. A bunch of components need to load data, but there’s a single fallback in your code that can display a loading spinner, instead of each component managing their loading states independently and creating a UX from hell with dozens of spinners everywhere.

Suspense components then work off of two main states. The first is “pending”, while your async code is still running, and then it goes into a “resolved” state once your async code is done (and the underlying Promise has been resolved).

But what happens when the component inside of a Suspense slot is changed? For example, the Dashboard component in the above example? Or what about when we change routes in our Nuxt app and the component/page rendered inside of the NuxtPage component changes?

This is the piece that I was looking for!

In reading the docs again, I learned that the Suspense component will “revert” back to the “pending” state, but it will keep rendering the previous state until a timeout is reached, and then will show the “fallback” again.

This is the mechanism that Nuxt relies on to switch between routes neatly.

When the route changes, the new page component is loaded asynchronously. This causes the Suspense inside of the NuxtPage to revert back, but for a time it will show the previous page still. So we get the new page to load in the background while the old page is still shown.

This is also necessary for transitions to work, since we can’t completely get rid of the previous route immediately. Otherwise, we’d have nothing to transition from!

Wrapping Up

Sometimes I like to understand the exact mechanisms behind how things work, and spend way too much time digging into things like this.

Hopefully you enjoyed this article as much as I enjoyed researching and writing it, and hopefully you know a bit more about how Nuxt works.

Michael Thiessen
Michael is a passionate full time Vue.js and Nuxt.js educator. His weekly newsletter is sent to over 11,000 Vue developers, and he has written over a hundred articles for his blog and VueSchool.

Follow MasteringNuxt on