DEV Community

Cover image for A comprehensive guide to Dynamic Imports and Lazy Hydration in Nuxt 3
Alex Marusych
Alex Marusych

Posted on • Edited on

A comprehensive guide to Dynamic Imports and Lazy Hydration in Nuxt 3

In the Nuxt 3 documentation, there’s a well-known article about the components directory in your app. It includes a section on Dynamic Imports, which has been there for a long time. Recently, the Nuxt team added another section called Delayed (or Lazy) Hydration. Interestingly, both features use the same approach — you add the Lazy prefix to a component name — but they serve different purposes.

That’s where confusion often begins. Many newer developers tend to mix these two up, so this article is here to explain the difference clearly and show how to use both features properly.

To demonstrate this, I created a small Nuxt app that uses both dynamic imports and lazy hydration. The repository is public, so you can clone it and try everything out yourself.

The app is simple: it includes a header, a long scrollable section with a button, a footer, and a modal window.

<!-- app.vue -->

<template>
  <main>
    <header>Header</header>
    <section>
      <h1>The too long section</h1>
      <button @click="toggleModalWindow">
        Open the modal window
      </button>
    </section>
    <LazyAppFooter
      hydrate-on-visible
      @hydrated="onFooterHydrate"
    />
    <LazyAppModalWindow
      v-if="isModalWindowOpened"
      @close="toggleModalWindow"
    />
  </main>
</template>

<script setup>
  const isModalWindowOpened = ref(false);

  const onFooterHydrate = () => console.log('Footer has been hydrated');
  const toggleModalWindow = () => isModalWindowOpened.value = !isModalWindowOpened.value;
</script>

<style lang="css">
  body {
    background-color: black;
    box-sizing: border-box;
    color: white;
    font-size: 24px;
    text-align: center;
  }

  header {
    position: sticky;
    top: 0;
    padding: 20px;
    background-color: black
  }

  section {
    height: 2000px;
    background-color: rgba(255, 255, 255, 0.5);
    padding: 100px 20px;
  }

  button {
    border: none;
    outline: none;
    height: 44px;
    background-color: chocolate;
    color: white;
    border-radius: 4px;
    font-size: 14px;
    cursor: pointer;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Let’s start with Dynamic Imports since this feature has existed in Nuxt for quite some time and is already familiar to many developers.

Most Nuxt apps have components that are not immediately visible when the page loads. These could be modal windows, tooltips, dropdowns, or mobile menus. They typically appear only after the user interacts with something. The important part is that the user might never trigger these components at all. For example, someone browsing your site on a desktop won’t see the mobile menu. Separately, they might never open a review popup or fill in a form at all.

However, if you don’t optimize your app, these hidden components may still be rendered on the server and their JavaScript will be downloaded on the client, even though they are not needed. Wrapping them in a client-only tag prevents server rendering but doesn’t stop the browser from downloading the JavaScript.

Dynamic imports are a solution to this. By adding the Lazy prefix to a component and wrapping it in a v-if condition, you ensure that Nuxt doesn’t include the component in the server-rendered HTML, and the browser doesn’t download its JavaScript unless it actually gets rendered.

In my demo app, I use a modal window that only appears after clicking a button.

<!-- AppModalWindow.vue -->

<template>
  <div
    class="modal-window-container"
    @click="close"
  >
    <div class="modal-window">
      <h2>Modal window</h2>
      <p>
        This component is not even included in the initial HTML. When Vue is generating HTML on the server it just skips this component. Only if you click on the button to open the window, the browser downloads the whole component as a JS file.
      </p>
    </div>
  </div>
</template>

<script setup>
const emit = defineEmits(['close']);

const close = () => emit('close');

console.log('This debug will run only if you click on the button. You won\'t find it on the server side (in terminal)');
</script>

<style lang="css">
.modal-window-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100dvh;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal-window {
  padding: 20px;
  background-color: white;
  border-radius: 12px;
  width: calc(100% - 30px);
  max-width: 600px;
  box-sizing: border-box;
  min-height: 400px;
  color: black;
}
</style>
Enter fullscreen mode Exit fullscreen mode

The modal component is wrapped in v-if="isModalWindowOpened", and I renamed it to LazyAppModalWindow.

<LazyAppModalWindow
  v-if="isModalWindowOpened"
  @close="toggleModalWindow"
/>
Enter fullscreen mode Exit fullscreen mode

This way, Vue skips it during SSR and doesn't include its JavaScript in the initial bundle. Once the condition is true, the browser downloads the JavaScript, and Vue mounts the component from scratch. It’s not hydrated, since it wasn’t rendered on the server.

So, if you have components that don’t contain valuable SEO information and are simply the result of user interaction, you can make them lazy-loaded. This will make your server-side HTML lighter, reduce the size of JavaScript chunks, and exclude these components from the initial hydration.

Now let’s move on to Lazy Hydration, which is a newer and often misunderstood feature.

There are many situations where you want a component to be part of the server-rendered HTML because it contains content valuable for SEO, but its JavaScript behavior is not necessary immediately. For example, think about a long marketing page where the footer is all the way at the bottom. The content is important, but its interactive features aren’t critical during the first seconds after page load.

Lazy hydration lets the component be in the HTML but delays loading and running its JavaScript. Just like with dynamic imports, you still add the Lazy prefix to the component name. But instead of controlling rendering with v-if, you use one of Nuxt’s hydration strategies — like hydrate-on-visible, hydrate-on-idle, hydrate-on-interaction and others.

In my app, the page is too long, and the footer is not in the viewport right at the beginning. So, if the footer has some client-side JavaScript logic, it’s definitely useless when the user doesn’t see it yet. That’s why I decided the footer’s JavaScript should be downloaded only when the component enters the screen — this is actually the most common case discussed.

<LazyAppFooter
  hydrate-on-visible
  @hydrated="onFooterHydrate"
/>
Enter fullscreen mode Exit fullscreen mode

This means the footer is rendered during SSR and appears in the HTML from the start, but its JavaScript won’t be downloaded or executed until the user scrolls down and the footer enters the viewport.

To show exactly what happens, I placed three debug logs in the footer component. One runs when executes, another runs inside the onMounted lifecycle hook, and the third is triggered by the hydrated event.

<!-- AppFooter.vue -->

<template>
  <footer>
    Footer. This component is included in the initial HTML. When Vue.js is running on the server it runs this component and renders in the HTML. But the corresponding JavaScript is loaded only when the specific hydration statement is achieved. In our case it's when component is visible.
  </footer>
</template>

<script setup>
onMounted(() => console.log('Footer has been mounted on the client side'));

console.log('This debug runs twice: the first one on the server side (check in the terminal), and then on the client side when component is in the viewport');
</script>

<style lang="css">
footer {
  padding: 20px;
  background-color: black
}
</style>
Enter fullscreen mode Exit fullscreen mode

So, what’s going on in this case? When Vue runs on the server side, it renders the footer. Hence our first debug inside script setup always runs on the server. Since onMounted is only available on the client, Vue skips it during SSR — but that code still exists in the client-side JS file.

After the initial page load, the browser starts downloading all necessary JavaScript files. However, if the footer isn’t in the viewport, the browser skips downloading the footer’s JS. Only when the user scrolls down and the footer starts to appear does the browser finally download the corresponding JS file — and that’s when the hydration process for the footer begins.

The first debug runs again, this time on the client. Then onMounted is triggered, and finally the hydrated callback runs. At that point, you’ll see the message “Footer has been hydrated” in the browser console.

Lazy hydration is useful when the component should be part of the SSR HTML for SEO purposes, but its interactivity can wait until it becomes visible, idle time is reached, or the user interacts with it. This way, you reduce the amount of JavaScript the browser needs to handle upfront, without losing the SEO benefits of server rendering.

One important note is that lazy hydration is available starting from Nuxt version 3.16.0. So make sure you’re using a compatible version if you plan to apply this technique.

To summarize everything in one sentence: use dynamic imports when your component doesn’t need to be rendered on the server and is only used after interaction, and use lazy hydration when your component should be rendered on the server for SEO, but its client-side JavaScript can be delayed.

That’s it. Hope this article helped you understand how and when to use both dynamic imports and lazy hydration properly in Nuxt 3.


If you want to learn how to build fast, SEO-friendly web apps with Nuxt 3 — step by step — 👉 Check out my course here.

Top comments (0)