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.
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.{ 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: ... } }.nuxtApp.vueApp.use(...) / nuxtApp.vueApp.directive(...). Router-level additions use addRouteMiddleware and nuxtApp.$router.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.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.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/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).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.An API client plugin with ordering, a typed helper, and no work on the critical path that does not belong there:
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 } }
},
})
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:
<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:
export * from './auth/useUserSession'
export * from './auth/usePermissions'
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).
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.
console.log in setup, name them 03., 01. and 02., and confirm the order; then add enforce: 'pre' to 03. and watch it move.console.log in app.vue's setup and in a callOnce; load the page and count server and client logs for each.nuxt analyze, then move the import into onNuxtReady and compare the entry chunk."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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
State and runtime config
useState for SSR-safe shared state, what the payload can serialise, runtimeConfig versus app.config versus environment variables, useCookie, and when a store is the right tool.
Error handling
Where errors happen in a Nuxt app and where they surface — createError and error.vue, fatal versus non-fatal, NuxtErrorBoundary, useAsyncData errors, server route errors, hooks and monitoring.