Skip to content
Release notesNuxt

Nuxt 3.17: the async data layer, reworked

Nuxt 3.17.0 reached npm on 27 April. The headline is a rebuilt data layer under useAsyncData and useFetch; the rest is a new component, better warnings and a faster build.

vuejs.school team29 Apr 20258 min readNuxt 3.17

Nuxt 3.17.0 was published to npm on 27 April 2025, and a 3.17.1 patch followed two days later. The headline of the release post is a rework of useAsyncData and useFetch: calls with the same key now share one set of refs, keys can be reactive, and refetches are deduplicated when several components with one key watch the same source. The release also adds a <NuxtTime> component, updates to three built-in components, development warnings and faster builds.

The upgrade is one command, but Nuxt asks you to test thoroughly afterwards. Read the data sections first if your app uses one key in more than one place, uses getCachedData, or reads cached data after the components that fetched it have unmounted. The examples use the Nuxt 3 layout: pages/, components/ and composables/ at the project root.

What shipped

From the release post, grouped the way it groups them:

  • Data fetching: one shared instance per key, reactive keys, and deduplicated refetches when several components watch the same source. The release post says the breaking changes sit behind experimental.granularCachedData, which is off by default.
  • Components: a new <NuxtTime> for rendering dates without hydration mismatches, <NuxtErrorBoundary> rewritten as a single-file component, and new props on <NuxtLink> and <NuxtLoadingIndicator>.
  • Developer experience: a warning when a server component has no single root element; a warning when you use the runtimeConfig.app namespace, which Nuxt reserves for itself; a warning when a core auto-import preset is overridden; and an error when definePageMeta appears twice in one file, which can happen mid-refactor.
  • Module authors: experimental.enforceModuleCompatibility makes Nuxt stop when an incompatible module loads (off by default; the release post says it will be on by default in the next major version), and addComponentExports registers every named export of a file as a component.
  • Performance: file globbing moved to tinyglobby, the .data directory is excluded from type-checking, and the purgeCachedData check was hoisted so it tree-shakes better.
  • Documentation: the raw Markdown and YAML of the docs is published on npm as @nuxt/docs.

Calls with the same key share one set of refs

The key is the first argument to useAsyncData ('users' below), the name Nuxt stores the result under. Before 3.17, each call built its own instance around that key's refs, with its own refresh and watchers. Two plain calls with one key did share their refs, but not always: a second call with immediate: false, for example, made new ones, and the first component never saw what the second fetched.

From 3.17, all calls with the same key, from useAsyncData or useFetch, share one instance: the same data, error and status refs, and one refresh. Refresh in one place and every component using the key updates, and refreshNuxtData('users') runs the handler once rather than once per call.

Here are two components that use the users key:

components/UserCount.vue
<script setup lang="ts">
const { data: users } = useAsyncData('users', () => $fetch('/api/users'))
</script>

<template>
  <p>{{ users?.length ?? 0 }} users</p>
</template>
components/UserList.vue
<script setup lang="ts">
const { data: users, status, refresh } = useAsyncData('users', () => $fetch('/api/users'))
</script>

<template>
  <button type="button" :disabled="status === 'pending'" @click="refresh()">Reload</button>
  <ul>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

Put both on one page. Clicking "Reload" in the list updates the count too, because both components read the same refs.

Sharing an instance means the calls have to agree on how the data is produced. The pull request behind the change splits the options in two:

  • Must match across calls with one key: handler, deep, transform, pick, getCachedData and default. If they differ, Nuxt prints a development warning that starts [nuxt] [useAsyncData] Incompatible options detected for "users" and lists the differences.
  • May differ, because they describe when and where to fetch: server, lazy, immediate, dedupe and watch.

So if a key appears in more than one place, move the call into a composable so there is one definition:

composables/useUsers.ts
export function useUsers() {
  return useAsyncData('users', () => $fetch('/api/users'))
}

Reactive keys follow a changing prop or ref

The key can now be a computed ref, a plain ref or a getter function. When it changes, Nuxt fetches again under the new key and, if no other component still uses the old key, cleans up the old entry. This matters most in a component that stays mounted while its input changes, such as a card that shows whichever user is selected:

components/UserCard.vue
<script setup lang="ts">
const props = defineProps<{ userId: string }>()

const { data: user, status } = useAsyncData(
  () => `user-${props.userId}`,
  () => $fetch(`/api/users/${props.userId}`),
)
</script>

<template>
  <p v-if="status === 'pending'">Loading</p>
  <h2 v-else-if="user">{{ user.name }}</h2>
</template>

A parent renders <UserCard :user-id="selectedId" /> and changes selectedId when the visitor picks another user. The card stays mounted, its key changes from user-1 to user-2, and the fetch follows. Before 3.17 the key had to be a plain string, read once when the component was set up. To refetch when the prop changed you added a watch option, and every user was stored under that first key. A reactive key gives each user an entry of its own.

Pages are different. By default <NuxtPage> mounts a fresh page component when a route parameter changes, so going from /users/1 to /users/2 runs the setup of pages/users/[id].vue again, and a string key built from route.params.id was already right before 3.17.

Watchers on one key are deduplicated

The watch option takes an array of reactive sources and refetches when any of them changes. Several components on one page often watch the same source: a paginated list, a page counter and a "showing 1 to 20" label might all follow () => route.query.page. In 3.16 each of them refetched. In 3.17, when they share a key, the release post says a change to the source triggers a single fetch instead of one per component. Nothing in your code changes to get this; the call in each component looks as it always has:

const route = useRoute()

const { data: posts } = useAsyncData(
  'posts',
  () => $fetch('/api/posts', { query: { page: route.query.page } }),
  { watch: [() => route.query.page] },
)

The granularCachedData and purgeCachedData flags

Two experimental options control the parts of the rework that could change what your app does. Both concern the cache behind getCachedData, the option that returns stored data so Nuxt can skip a request.

experimental.granularCachedData is off by default (on if you set future.compatibilityVersion: 4 to try the next major version's defaults). The config reference describes it as "whether to call and use the result from getCachedData on manual refresh for useAsyncData and useFetch".

  • Off: Nuxt calls getCachedData only on the initial fetch and during hydration, which is what 3.16 did.
  • On: Nuxt also calls it before every refetch, including those caused by watch and refreshNuxtData.

Either way, getCachedData now receives a third argument whose cause says why it was called: initial, refresh:hook (from refreshNuxtData), refresh:manual (from refresh()) or watch.

experimental.purgeCachedData is on by default, described as "whether to clean up Nuxt static and asyncData caches on route navigation". In 3.17 that includes clearing a key's data when the last component using it unmounts. The release post says that if your app relied on cached data staying available after those components unmounted, you can turn it off to get the previous behaviour:

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    purgeCachedData: false,
  },
})

Set it only if the upgrade breaks something, and treat the line as a note to fix the code that depended on a stale cache. Data a page needs after navigating away belongs in a store or in a fresh fetch, not in a cache entry that happens to survive.

NuxtTime and updates to three built-in components

Rendering a date is a classic hydration problem: the server formats it in one time zone and the browser in another, and Vue warns that the text does not match. The release post calls <NuxtTime> an SSR-safe time display that resolves these mismatches:

<NuxtTime :datetime="post.publishedAt" year="numeric" month="long" day="numeric" />

It renders a <time> element, and the extra attributes are the usual Intl.DateTimeFormat options. On the server it writes those options into data- attributes, and a small script formats the date again in the browser before Vue hydrates, so both sides render the same text.

Three other components gained props or exposed values:

  • <NuxtErrorBoundary> exposes error and clearError in its error slot types and through useTemplateRef, so a parent can reset it.
  • <NuxtLink to="/about" trailing-slash> renders href="/about/", for sites whose server wants a trailing slash.
  • <NuxtLoadingIndicator :hide-delay="500" :reset-delay="300" /> controls how long the bar waits before hiding and before resetting its state.

How to upgrade

Nuxt recommends one command, which also refreshes the lockfile so the packages underneath Nuxt move together:

npx nuxi@latest upgrade --dedupe

Then run the app in development and click through every page that fetches data. Three things to look for:

  1. An Incompatible options detected warning: two calls with the same key and a different handler, transform, pick or default. Merge them into one composable.
  2. Code that reads a key's data after every component using it has unmounted, for example with useNuxtData. With purgeCachedData on, the data is gone. Fetch it again or keep it in a store.
  3. A watch option combined with a static key where you wanted separate cache entries. Switch to a reactive key.

Upgrade now if several components share keys in your app, because shared refs keep them in step, and reactive keys replace the watch workaround in components whose input changes. Wait a week or two if you depend on getCachedData for an offline-first flow: the purge clears nuxtApp.payload.data[key], which a custom getCachedData may read. Read the pull request first and test it against your cache logic.

Check it stuck3 questions from the Data fetching quiz.
Practice now
Was this helpful?

Keep reading