Skip to content
Release notesNuxt

Nuxt Scripts 1.0: third-party scripts without the slowdown

@nuxt/scripts 1.0.0 was published on 15 April, after almost three months of betas and release candidates. It serves supported scripts from your own domain and adds consent controls for each vendor, and a script listed in nuxt.config.ts no longer loads on every page without a trigger.

vuejs.school team16 Apr 20266 min read@nuxt/scripts 1.0 · Nuxt 4.4

Analytics tags, chat widgets, maps and video players are written by other companies, and they often cost more load time than your whole Vue app. Nuxt Scripts is the official Nuxt module for loading them on your terms: after the page is interactive, when an element scrolls into view, when the user clicks, or only after they accept cookies.

Version 1.0.0, the first stable release, was published to npm on 15 April, following betas from January and release candidates from 30 March. Among the highlights in the v1.0.0 release notes are first-party mode, which serves supported scripts from your own domain, and consent controls for each vendor. If you already use 0.x, one change affects almost every app: a script configured under scripts.registry in nuxt.config.ts no longer loads on every page unless you give it a trigger. The examples use Nuxt 4.4 with the app/ folder.

How the module loads a script

Everything goes through useScript(), an extended version of the composable from Unhead, the library behind Nuxt's useHead(). Its defaults keep scripts out of the way:

  • Scripts are not put into the server-rendered HTML, so they cannot hold up hydration.
  • They load with the onNuxtReady trigger: once Nuxt has hydrated and the browser is idle.
  • Script tags get crossorigin="anonymous" and referrerpolicy="no-referrer", so the request for the script file sends no cookies and no page URL to the third party.

A trigger decides when a script loads, and any reactive value or promise can be one. Here a newsletter embed loads only when its section scrolls into view:

app/components/NewsletterSignup.vue
<script setup lang="ts">
const formEl = ref<HTMLElement>()

useScript('https://forms.example.com/embed.js', {
  trigger: useScriptTriggerElement({ trigger: 'visible', el: formEl }),
})
</script>

<template>
  <section ref="formEl">
    <h2>Get the newsletter</h2>
  </section>
</template>

The formEl template ref tells the trigger which element to watch. Other triggers cover the remaining cases:

  • trigger: 'manual' waits until you call the returned load(), for example from an "Open chat" button.
  • useScriptTriggerIdleTimeout({ timeout: 5000 }) waits five seconds after Nuxt is ready.
  • useScriptTriggerInteraction({ events: ['scroll', 'click', 'keydown'] }) waits for the first of the events you list.

You can call a script's functions before it arrives through the proxy object that useScript() returns. A call such as proxy.gtag('event', 'signup') is queued and replayed once the script loads. If an ad blocker stops the script, the queued calls are dropped and nothing throws.

What shipped in 1.0

Several of the changes concern the registry, the module's list of first-class integrations: analytics (Google Analytics, Plausible, Fathom, Umami, Matomo and others), Google Tag Manager and ad pixels, Google Maps, YouTube and Vimeo players, Stripe and PayPal, and chat widgets such as Intercom and Crisp. Each comes with a typed composable such as useScriptGoogleAnalytics(), options validated in development, and loading tuned for that script.

The release notes lead with these:

  • First-party mode. Supported scripts are downloaded at build time and served from /_scripts/assets/ on your domain, and their tracking requests go through a proxy on your server at /_scripts/p/, which removes cookies and shortens IP addresses before forwarding. It is on by default for every script that supports it.
  • Consent controls. Consent-aware registry scripts take a typed defaultConsent option and return a consent object that speaks the vendor's own API, such as Google's Consent Mode for Analytics and Tag Manager or grant and revoke for the Meta Pixel.
  • Server-rendered social embeds. ScriptXEmbed, ScriptInstagramEmbed and ScriptBlueskyEmbed fetch the post's data on the server and proxy its assets through your domain, in place of the vendor's embed script.
  • New registry scripts: PostHog, Google reCAPTCHA v3, TikTok Pixel, Google Sign-In, Rybbit, Databuddy, Bing UET, Mixpanel, Vercel Analytics and Gravatar.
  • Smaller additions: a reload() method for scripts that scan the page once, automatic Subresource Integrity hashes for bundled scripts, IDs read from NUXT_PUBLIC_SCRIPTS_* environment variables, a rebuilt DevTools panel, and large reworks of the Google Maps and YouTube player components.

Install the module with npx nuxt module add scripts, then register the script in nuxt.config.ts:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/scripts'],
  scripts: {
    registry: {
      // sets up bundling, the proxy and types; a component decides when to load
      googleAnalytics: { id: 'G-XXXXXXXX', trigger: false },
    },
  },
})

trigger: false matters. It tells the module not to load the script globally, because a component will. The component holds the consent trigger, so the banner and the script share it:

app/components/CookieBanner.vue
<script setup lang="ts">
const consent = useScriptTriggerConsent()

useScriptGoogleAnalytics({
  scriptOptions: { trigger: consent },
})
</script>

<template>
  <div v-if="!consent.consented.value" role="region" aria-label="Cookie consent">
    <p>We use Google Analytics to see which pages people read. Nothing loads until you agree.</p>
    <button type="button" @click="consent.accept()">Accept analytics</button>
  </div>
</template>

Put <CookieBanner /> in app.vue. Until the button is clicked, no Google request leaves the browser. useScriptGoogleAnalytics() reads the ID from the registry entry, so it is not repeated here.

In a real app, store the choice (a cookie or localStorage) and pass it as useScriptTriggerConsent({ consent: savedChoice }), so returning visitors are not asked again.

If you would rather load Google Analytics straight away in its restricted mode, use Consent Mode instead: pass defaultConsent: { analytics_storage: 'denied', ad_storage: 'denied' } and call consent.update() with 'granted' values when the user agrees. Pick the load gate when your rule is "nothing before yes"; it is the simpler promise to keep.

A YouTube embed that waits for a click

A YouTube iframe pulls in hundreds of kilobytes before anyone presses play. The facade components avoid that. They render a light placeholder, here the video's thumbnail, and swap in the real player only on an interaction:

app/pages/talks.vue
<template>
  <ScriptYouTubePlayer video-id="d_IFKP1Ofq0" ratio="16/9">
    <template #loading>
      <ScriptLoadingIndicator />
    </template>
    <template #error>
      <p>The video could not load. <a href="https://www.youtube.com/watch?v=d_IFKP1Ofq0">Watch it on YouTube</a>.</p>
    </template>
  </ScriptYouTubePlayer>
</template>

The player script loads on mousedown by default, so the click that starts loading is the click that starts the video. Embeds use youtube-nocookie.com unless you set the cookies prop. In 1.0 several players on one page no longer interfere with each other, and players are destroyed on unmount. If the video sits at the top of the page, add above-the-fold so the thumbnail loads eagerly.

What first-party mode covers and where it stops

When a script is both bundled and proxied, as Google Analytics is, the browser makes no DNS lookup for the vendor, gets no third-party cookies, and ad blockers see same-origin requests, a side effect the release notes state openly.

Beyond cookies and IP addresses, what the proxy strips depends on the script. Ad pixels lose the most detail. Google Analytics, Microsoft Clarity and Hotjar keep the screen size and user agent that heatmaps need. The rest, such as Plausible or the YouTube player, lose nothing more.

Not every script gets all of this. Google Tag Manager, Segment, Crisp, Mixpanel and Bing UET are bundled but not proxied. Stripe, PayPal, reCAPTCHA and Google Sign-In load straight from the vendor, because their security checks need a direct connection. Nor does first-party mode replace consent: you still need it before loading a tracking script.

The proxy needs a server. With nuxt generate it switches itself off: scripts are still bundled and served from your domain, but their tracking requests go straight to the vendor. To turn the proxy off for one script, set proxy: false on its registry entry; bundle: false turns off both bundling and the proxy.

Upgrading from 0.x

Where it can, 1.0 keeps old APIs working with deprecation warnings, and the migration guide lists each breaking change and deprecation. One change affects almost everyone: a registry entry no longer loads its script on its own. In 0.x, googleAnalytics: { id: 'G-XXXXXX' } meant "load on every page". In 1.0 it only sets up types, bundling and the proxy, and the build warns you. Add trigger: 'onNuxtReady' to keep the old behaviour, or trigger: false if a composable loads it.

The other changes are narrower:

  • PayPal moved to its v6 SDK, and the v5 API is gone.
  • The YouTube placeholder now uses object-fit: cover, and width and height no longer set the aspect ratio: use ratio.
  • Google Maps lost its markers and placeholder props in favour of child components.
  • The true shorthand for registry entries is deprecated.

Then install 1.0 and refresh the generated types, whose templates were reorganised:

npm install @nuxt/scripts@^1.0.0
npx nuxt prepare

Upgrade now if you are on 0.x: for most apps, the one required change is a config line the build points you to. First-party mode then turns itself on, so after you deploy to a server, look for requests to /_scripts/p/ in the browser's Network tab to confirm the proxy works.

Check it stuck3 questions from the Modules, plugins and layers quiz.
Practice now
Was this helpful?

Keep reading