Building Nuxt apps

Plugins and auto-imports

Plugin order, dependsOn and parallel, providing typed helpers, app hooks, callOnce and onNuxtReady, and how components, composables and utils are scanned and named.

Plugins are where a Nuxt app integrates the outside world: an API client, an analytics SDK, a Vue plugin, an error reporter. They run on every request on the server, which makes them the second most common source of leaks and slow TTFB after data fetching. Auto-imports are the other half of this page because "why is my composable not defined" and "why is my component named that" are questions you will be asked to explain.

Know

  • Plugins register themselves. Every file at the top level of app/plugins/ runs in alphabetical order (prefix with 01. to order), .client.ts and .server.ts suffixes restrict the side. Subdirectories are not scanned unless you re-export.
  • defineNuxtPlugin object syntax gives you name, enforce: 'pre' | 'post', dependsOn: ['other-plugin'], parallel: true (do not block the next plugin on this one's async work), setup(nuxtApp), hooks: { 'app:mounted'() {} }, and env: { islands: false } to skip server components. The function form is fine for simple plugins.
  • Providing helpers. Return { provide: { api } } and it appears as useNuxtApp().$api and $api in templates; the type is inferred from the return value. With the object syntax the inference may need a manual declare module '#app' { interface NuxtApp { $api: ... } }.
  • Vue plugins and directives go through nuxtApp.vueApp.use(...) / nuxtApp.vueApp.directive(...). Router-level additions use addRouteMiddleware and nuxtApp.$router.
  • Server cost. A plugin's setup runs for every server-rendered request. Heavy synchronous work there is added latency for every user; a top-level SDK import lands in every page's entry chunk. Import lazily, use .client when the code has no server purpose, and defer with onNuxtReady for anything that can wait until after hydration.
  • App hooks (nuxtApp.hook(name, fn)): app:created, app:beforeMount, app:mounted, app:suspense:resolve, page:start, page:finish, page:loading:end, link:prefetch, app:error, vue:error, app:chunkError, app:manifest:update, app:data:refresh. They are how plugins react to navigation and errors without touching pages.
  • callOnce(key, fn, { mode }) runs a function once per app load rather than once on the server and once on the client, which is what you want for initialisation in app.vue or a layout; mode: 'navigation' re-runs it on each client-side navigation. onNuxtReady(fn) runs after hydration, on the client, when the browser is idle.
  • Auto-imports cover Vue and Nuxt APIs, the top level of app/composables/ and app/utils/, and shared/utils and shared/types (available on both sides, forbidden from importing Vue or Nitro). Nested folders need an index.ts re-export or imports.dirs. #imports is the explicit import path; imports.autoImport: false turns the magic off while keeping #imports.
  • Components are scanned recursively and named by path: components/Team/Button.vue → <TeamButton> (pathPrefix: false to disable). Lazy prefix creates an async component; .client.vue/.server.vue decide the side; components/global/ registers globally (avoid in libraries, it defeats code splitting).
  • Environment flags: import.meta.client, import.meta.server, import.meta.dev, import.meta.prerender, import.meta.test. The process.client/process.server flags are gone in Nuxt 4 types.

How it works

An API client plugin with ordering, a typed helper, and no work on the critical path that does not belong there:

app/plugins/01.api.ts
export default defineNuxtPlugin({
  name: 'api',
  enforce: 'pre', // other plugins may depend on $api
  setup(nuxtApp) {
    const { public: { apiBase } } = useRuntimeConfig()
    const api = $fetch.create({
      baseURL: apiBase,
      headers: import.meta.server ? useRequestHeaders(['cookie']) : undefined,
      onResponseError({ response }) {
        if (response.status >= 500) nuxtApp.callHook('app:error', new Error(`API ${response.status}`))
      },
    })
    return { provide: { api } }
  },
})
app/plugins/02.analytics.client.ts
export default defineNuxtPlugin({
  name: 'analytics',
  dependsOn: ['api'],
  parallel: true,
  setup(nuxtApp) {
    // Nothing until the page is interactive; the SDK is a dynamic import so it never joins the entry chunk.
    onNuxtReady(async () => {
      const { init, track } = await import('~/lib/analytics')
      init(useRuntimeConfig().public.posthogKey)
      nuxtApp.hook('page:finish', () => track('$pageview', { path: useRoute().fullPath }))
    })
  },
})

Initialisation that must not run twice, in app.vue:

app/app.vue
<script setup lang="ts">
// Runs on the server during SSR; the client sees the payload flag and skips it. On a pure client
// navigation flow it runs once in the browser.
await callOnce('bootstrap', async () => {
  const flags = useState('flags')
  flags.value = await $fetch('/api/flags')
})
</script>

<template>
  <NuxtLoadingIndicator />
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

Re-exporting a nested composables folder so the scanner sees it:

app/composables/index.ts
export * from './auth/useUserSession'
export * from './auth/usePermissions'
Gotcha· A plugin that imports the SDK at the top

import posthog from 'posthog-js' at the top of a universal plugin puts the whole library into the entry chunk of every page, on every device, whether or not analytics ever fires. The fix is a .client plugin, a dynamic import, and a trigger (onNuxtReady, or @nuxt/scripts for third-party scripts).

Gotcha· Composable in a subfolder

app/composables/cart/useCart.ts is not auto-imported and the error only appears at runtime as "useCart is not defined". Either re-export from composables/index.ts, configure imports.dirs: ['composables/**'], or import it explicitly. Check .nuxt/imports.d.ts when something is mysteriously undefined.

Exercise

Exercise
  • Write three plugins with console.log in setup, name them 03., 01. and 02., and confirm the order; then add enforce: 'pre' to 03. and watch it move.
  • Put a console.log in app.vue's setup and in a callOnce; load the page and count server and client logs for each.
  • Import a large library at the top of a plugin, run nuxt analyze, then move the import into onNuxtReady and compare the entry chunk.

Be able to say

Be able to say· How do plugins work in Nuxt and what do you watch out for?

"Files in app/plugins run in alphabetical order when the app is created, on the server for every request and on the client once, with .client and .server suffixes to restrict the side. The object form gives me enforce, dependsOn and parallel for ordering, provide for typed helpers that appear on useNuxtApp() and in templates, and hooks for reacting to the app lifecycle. What I watch out for is cost: setup runs per request on the server, so nothing heavy there, and a top-level SDK import lands in every page's entry chunk, so third-party code is a client plugin with a dynamic import behind onNuxtReady."

Be able to say· How does auto-import decide what is available?

"Nuxt scans the top level of composables and utils, the shared/utils and shared/types folders for both sides, and components recursively with path-based names, and it exposes Vue and Nuxt APIs directly. The generated .nuxt/imports.d.ts is the truth: if something is 'not defined' I look there first. Nested composable folders need a re-export or imports.dirs; #imports gives an explicit import path; and I can disable scanning with imports.autoImport: false for a codebase that prefers explicit imports without losing the #imports alias."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.