Skip to content
Release notesNuxt

Nuxt 4.4: createUseFetch(), Vue Router 5 and useAnnouncer()

Nuxt 4.4 was released on 12 March. The feature you will use every day is createUseFetch(): one file that holds your API's base URL and auth header, so no page has to repeat them.

vuejs.school team19 Mar 20266 min readNuxt 4.4 · Vue Router 5.0

Nuxt 4.4 was released on 12 March. It is a minor release, so nothing breaks on purpose, but it adds three things a typical app will want: a factory for your own useFetch, an upgrade to Vue Router 5, and a way to tell screen reader users about changes on the page.

It matters most if your pages repeat the same base URL and auth header on every useFetch call, or if you installed unplugin-vue-router yourself. It does not bring Vite 8, which came out the same day. The examples use the Nuxt 4 layout: pages/, components/, composables/ and layouts/ live inside app/, and server/ stays at the project root.

What shipped

The release post lists these changes:

  • createUseFetch() and createUseAsyncData(), factories that return your own version of useFetch or useAsyncData with default options built in.
  • Vue Router 5 replaces Vue Router 4 and drops the separate unplugin-vue-router dependency.
  • Layouts accept props from definePageMeta, and those props are typed from the layout's defineProps.
  • useAnnouncer() and the <NuxtAnnouncer> component, for announcing in-page changes to screen readers.
  • Route generation moved to a library called unrouting. After a change in development, routes are rebuilt up to 28 times faster when no page was added or removed, and about 15 percent faster when one was.
  • An experimental payloadExtraction: 'client' mode for routes cached with ISR or SWR, plus an in-memory payload cache for those routes that needs no setting.
  • A refresh option for useCookie, and a reset-to-default option for useState.
  • Clearer import protection errors with a trace of where a server-only import came from, view transition types, copy-pasteable hints when Vite discovers dependencies late, and nuxt build --profile.

Build a preconfigured fetcher with createUseFetch

Most apps talk to one API. Every call needs the same base URL, and every call to a protected endpoint needs the same Authorization header. Until now you either repeated those options on each useFetch or wrote a wrapper by hand and lost some of the typing and key handling on the way. createUseFetch() gives you the wrapper with nothing lost.

The docs call createUseFetch a compiler macro, which means Nuxt rewrites the call while building. It only works as an exported declaration in app/composables/ (or another folder Nuxt scans). Nuxt then injects the keys that stop the same request running twice, once on the server and again during hydration. This fetcher adds the base URL and a bearer token to every call:

app/composables/useApi.ts
export const useApi = createUseFetch(() => {
  const config = useRuntimeConfig()
  const token = useCookie<string | null>('auth_token')

  return {
    baseURL: config.public.apiBase,
    onRequest({ options }) {
      if (token.value) {
        options.headers.set('Authorization', `Bearer ${token.value}`)
      }
    },
  }
})
  • config.public.apiBase comes from runtimeConfig.public.apiBase in nuxt.config.ts, which you can change per environment with the NUXT_PUBLIC_API_BASE variable.
  • The token lives in a cookie, so it is readable during server-side rendering as well as in the browser, and the server-rendered request carries the header too.
  • The header is set inside onRequest, so the token is read on every request, including a refresh() after the user logs in.

A page then calls useApi exactly as it would call useFetch, with the same options and the same return value:

app/pages/account.vue
<script setup lang="ts">
type Profile = { name: string; email: string }

const { data: profile, error } = await useApi<Profile>('/me')
</script>

<template>
  <p v-if="error">Could not load your account.</p>
  <dl v-else-if="profile">
    <dt>Name</dt>
    <dd>{{ profile.name }}</dd>
    <dt>Email</dt>
    <dd>{{ profile.email }}</dd>
  </dl>
</template>

createUseAsyncData() follows the same pattern for useAsyncData.

Defaults with an object, overrides with a function

The factory accepts two shapes, and they behave differently.

Pass a plain object and its options become defaults. A caller can override any of them. The release post's simplest example is a fetcher that never runs on the server: export const useClientFetch = createUseFetch({ server: false }).

Pass a function, as useApi does, and the options it returns win over the caller's. The function receives the caller's options, so it can read them before deciding, and it runs inside the caller's setup, which is why useRuntimeConfig() and useCookie() work there. Options the function does not return, such as query or lazy, still come from the caller.

Announce in-page changes with useAnnouncer

Nuxt already had <NuxtRouteAnnouncer>, which reads out the page title after a navigation. Many changes happen without a navigation, though: a filtered list updates, a form saves, a search returns nothing. A sighted user sees it. A screen reader user hears nothing unless the page says something. useAnnouncer() fills that gap.

First add the component once, in app.vue, next to the route announcer. Without it, the messages are never rendered into the page:

app/app.vue
<template>
  <NuxtAnnouncer />
  <NuxtRouteAnnouncer />
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

Then call polite() for news that can wait until the screen reader finishes its sentence, and assertive() for news that should interrupt. This order list fetches through the useApi composable from above:

app/components/OrderList.vue
<script setup lang="ts">
type Order = { id: number; customer: string }

const filter = ref<'open' | 'shipped'>('open')
const { polite, assertive } = useAnnouncer()

const { data: orders, error } = await useApi<Order[]>('/orders', {
  query: { status: filter },
})

watch(orders, (list) => {
  if (list) polite(`${list.length} ${filter.value} orders`)
})

watch(error, (err) => {
  if (err) assertive('Could not load orders. Try again.')
})
</script>

<template>
  <label for="order-filter">Show</label>
  <select id="order-filter" v-model="filter">
    <option value="open">Open orders</option>
    <option value="shipped">Shipped orders</option>
  </select>
  <ul>
    <li v-for="order in orders" :key="order.id">{{ order.customer }}</li>
  </ul>
</template>

Changing the select refetches, because filter is a ref inside query. Focus stays on the select while the list below it changes, which is exactly the case the release post describes: content that changes without a matching focus change.

The release post also says you do not need the announcer everywhere. Moving focus to new content, or native form validation, is often enough.

Vue Router 5 replaces Vue Router 4

Nuxt 4.4 depends on Vue Router 5. The Vue Router 5.0 release merged unplugin-vue-router, the plugin behind file-based routing and typed routes, into the core package. Its migration guide says there are no breaking changes for apps that did not use that plugin, and the Nuxt release post expects the upgrade to be transparent for most Nuxt apps.

If you installed unplugin-vue-router yourself, remove it from package.json and move any imports from it to the vue-router paths the migration guide lists. The release post names the next step as taking typed routes out of experimental status.

Pass typed props to a layout

Layouts can now take props from the page that uses them, which replaces the provide and inject workarounds people used to parameterise a layout. The page names the layout and its props in definePageMeta:

app/pages/dashboard.vue
<script setup lang="ts">
definePageMeta({
  layout: {
    name: 'panel',
    props: { sidebar: true, title: 'Dashboard' },
  },
})
</script>

<template>
  <p>Welcome back.</p>
</template>

The panel layout, in app/layouts/panel.vue, declares sidebar and title with defineProps<{ sidebar?: boolean; title?: string }>(). Nuxt types the props object from that declaration, so a misspelt prop is an error in your editor.

Reset state, refresh cookies and profile builds

useState can now return to its initial value when you clear it. The release post's short example calls clearNuxtState('counter') alone, but that resets only with compatibilityVersion: 5 or the experimental.defaults.useState.resetOnClear setting. On a default Nuxt 4.4 config, pass { reset: true }:

app/composables/useCounter.ts
export function useCounter() {
  const count = useState('counter', () => 0)
  const reset = () => clearNuxtState('counter', { reset: true })
  return { count, reset }
}

Without the option, count.value becomes undefined, as before.

useCookie takes refresh: true, which extends a cookie's expiry each time you assign it, even when the value is the same. The expiry does not move on its own, so assign the value when the user does something (session.value = session.value is enough). That keeps a session cookie alive while the user is active.

nuxt build --profile writes three reports: a Chrome trace in .nuxt/perf-trace.json, a JSON report in .nuxt/perf-report.json and a CPU profile in nuxt-build.cpuprofile. Run it once on a slow build to see which module or plugin takes the time.

Upgrade, and where Vite 8 stands

The release post recommends npx nuxt upgrade --dedupe, which also deduplicates your lockfile. Run it, then check what you ended up with:

npx nuxt upgrade --dedupe
npm ls nuxt vue-router vite

You should see nuxt 4.4.2, vue-router 5.0 and, for now, vite 7.3.

Vite 8.0 came out about two hours after Nuxt 4.4 on the same day, and the release post says nothing about it. The package does: @nuxt/vite-builder 4.4.2 depends on vite ^7.3.1, so Nuxt 4.4 builds with Vite 7.3. The changelog includes a fix that makes Nuxt's SSR styles plugin compatible with Rolldown, which is groundwork rather than support. Do not force Vite 8 into a Nuxt project with a package manager override; wait for a Nuxt release that lists it.

Upgrade now. Vue Router 5 is a quiet change for most apps, and createUseFetch() alone is worth the afternoon it takes to move your API calls into one composable.

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

Keep reading