When app wide initialization is needed, and when reusable logic is
Nuxt plugins and composables look like they solve similar problems at first glance, since both place code outside a single component. In reality they pursue different goals: a plugin initializes something once for the entire application, while a composable provides reusable, composable logic that gets used independently across any number of components.
Table of Contents
- 1. The core difference: one time initialization vs. reusable logic
- 2. When a Nuxt plugin is the right choice
- 3. Registering a third party library via a plugin
- 4. When a composable is the right choice
- 5. provide/inject as a bridge between plugin and composable
- 6. Common mistake: logic wrongly implemented as a plugin
- 7. Client only, server only, and universal plugins
- 8. Testability: composables compared to plugins
- 9. A short decision guide for everyday use
- 10. Summary
- 11. FAQ
1. The core difference: one time initialization vs. reusable logic
A Nuxt plugin runs exactly once when the application starts, regardless of how many components later actually make use of it. It gets access to the Nuxt app context and can use it to register global Vue functionality such as app.use, app.component, or app.directive, or it can expose values via provide that then become available across the entire application without every component having to import them individually.
A composable, by contrast, is a function that creates a new, independent slice of reactive logic on every call. If a component calls useCounter(), it gets its own counter state, regardless of whether another component elsewhere also calls the same function. This per call isolation is the central difference from a plugin, which by definition only runs once, its result shared by every consumer.
2. When a Nuxt plugin is the right choice
A plugin fits whenever something needs to happen exactly once for the whole application, before the first component even renders. Typical examples include registering a global Vue directive, wiring up a third party library that itself manages global state or global configuration, or setting interceptors on an HTTP client instance meant to share the same base configuration across the entire application.
Providing a single, application wide shared instance of a service, for example a WebSocket client or an analytics tracker, also clearly belongs in a plugin, because it would make no sense to spin up a new connection or a new tracker on every component mount. The plugin creates the instance exactly once and exposes it via provide for every component that needs it, retrievable via inject or an accompanying composable.
3. Registering a third party library via a plugin
The classic example is a library like a toast notification system or a chart library that ships as a Vue plugin and itself expects app.use. In a Nuxt plugin under app/plugins, the Vue app instance is pulled out of the nuxtApp context and the third party library is registered exactly once, after which it can be used normally in every component through its own API as documented by the library.
It matters that Nuxt plugin files are automatically run on the client or server based on their filename, provided you use the .client.ts or .server.ts suffix. A library that accesses window or document must be marked as a client plugin, since it would otherwise crash during server side rendering, because those global objects simply do not exist on the server.
// app/plugins/toast.client.ts
import ToastLibrary from 'some-toast-library'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(ToastLibrary, {
position: 'top-right',
duration: 4000,
})
})
4. When a composable is the right choice
A composable fits whenever logic needs to be reused across several components, but every usage should have its own, independent state. A useLocalStorage composable, for instance, that synchronizes a value with localStorage, should create an independent ref for every call with a different key, not a single, application wide shared state that every caller reads and writes at once.
Plain utility functions with no Nuxt specific dependency, for instance a useDebounce or useMousePosition function, also belong as composables in the app/composables directory, where Nuxt automatically imports them without having to write an explicit import path in every component. Composables are therefore the tool of choice for anything you can think of as a parameterizable, composable unit of reactive logic that gets configured differently across different components.
5. provide/inject as a bridge between plugin and composable
In practice, plugins and composables are often used together: the plugin creates a shared instance once and exposes it via nuxtApp.provide, while an accompanying composable retrieves that instance via useNuxtApp() or inject and builds a cleanly typed, convenient API around it. That way, no component has to work directly with the raw inject call or the Nuxt app context, and instead simply calls useAnalytics() or useWebsocket().
This pattern cleanly separates where and how the one time initialization happens, which lives in the plugin, from how components access the result, which lives in the composable. If the underlying library or its configuration changes later, only the plugin needs adjusting, while the composable interface can stay unchanged for every existing call site, which makes refactoring considerably lower risk.
// app/plugins/analytics.client.ts
import { createAnalyticsClient } from 'analytics-sdk'
export default defineNuxtPlugin((nuxtApp) => {
const analytics = createAnalyticsClient({ apiKey: useRuntimeConfig().public.analyticsKey })
nuxtApp.provide('analytics', analytics)
})
// app/composables/useAnalytics.ts
export function useAnalytics() {
const { $analytics } = useNuxtApp()
return {
trackEvent: (name: string, payload?: Record<string, unknown>) =>
$analytics.track(name, payload),
}
}
6. Common mistake: logic wrongly implemented as a plugin
A recurring mistake is implementing logic that is actually reusable, for instance managing form state or querying an API with a loading state, directly inside a plugin, only because the plugin also has access to the Nuxt app context. The result is usually a single, application wide shared state, even though every component using that logic should really have its own independent instance, for example because two different forms on the same page would otherwise end up sharing the same loading state despite triggering completely independent requests.
The reverse mistake happens less often but is just as problematic: putting a library that genuinely only needs to be registered once, app wide, into a composable that tries to redo the same global registration on every component mount. For some libraries this merely causes unnecessary duplicate work, but for others, for example registering a global event listener without deduplication, it can cause tangible bugs such as events firing multiple times.
7. Client only, server only, and universal plugins
Nuxt distinguishes, via filename convention, between plugins that run on both sides, ones meant to run exclusively in the browser (.client.ts), and ones meant to run exclusively on the server (.server.ts). This distinction is an additional argument in favor of the plugin when deciding between plugin and composable, since a composable does not come with this environment separation built in, and you would have to add the equivalent check, for instance via import.meta.client, manually inside the composable's code.
For a library that strictly needs a browser API such as window.localStorage or a canvas surface, a .client.ts plugin is therefore usually the more robust choice compared to a composable that would have to check on every call whether it is currently running on the server or in the browser. Composables that genuinely work sensibly both server side and client side, for example plain data transformations with no browser dependency, do not benefit from this distinction and stay deliberately universal.
8. Testability: composables compared to plugins
Composables are generally much easier to test in isolation than plugins, because they can be called as plain functions and their return values checked directly in a unit test, if necessary with a minimal Vue test context via withSetup or a similar test helper. A plugin, on the other hand, is deeply intertwined with the Nuxt app lifecycle and can usually only be meaningfully tested in an integration or end to end test that spins up a full Nuxt app instance.
This testability consideration is another practical argument for bundling as much logic as possible into composables and deliberately keeping plugins thin, ideally containing only the actual registration of a library or instance, while any logic beyond that, such as formatting values or merging several data sources, gets extracted into a separate, independently testable composable.
9. A short decision guide for everyday use
Anyone unsure can lean on a simple question: should this code run exactly once when the application starts and then be shared by everyone, or should every component using it get its own independent instance? In the first case, the code belongs in a plugin, in the second case, in a composable. For shared instances that several components need to access, the combination of a thin plugin that only registers and exposes, paired with an accompanying composable that builds a convenient API around it, is in practice almost always the most maintainable solution.
This simple distinction between one time and reusable reliably resolves most edge cases and prevents both bloating plugins with logic that is actually composable and accidentally multiplying instances that should really only exist once. Applying this basic rule consistently across a team avoids most of the mistakes described above from the outset, without needing a complicated checklist for it.
| Criterion | Nuxt plugin | Composable | Rule of thumb |
|---|---|---|---|
| Execution frequency | Exactly once at app start | Fresh on every call | One time -> plugin, repeated -> composable |
| State | Shared application wide | Independent per call | Shared state points toward a plugin |
| Typical example | Registering a third party library via app.use | useDebounce, useLocalStorage, useFetch | Registration vs. reusable logic |
| Testability | Only meaningfully testable with a full Nuxt context | Testable in isolation as a plain function | More logic in composables makes testing easier |
| Environment control | Via filename suffix .client.ts / .server.ts | Checked manually via import.meta.client | Browser API dependency points toward a plugin |
Mironsoft
Vue architecture, Composition API, and Nuxt performance
Vue applications that don't get more complicated with every feature?
We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.
Architecture Review
Checking composables, state management, and component structure for maintainability.
Performance Audit
Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.
Nuxt Integration
Building robust, type-safe SSR/SSG setup and API integration.
10. Summary
Nuxt plugins vs. composables at a glance
Plugin
One time, app wide initialization, such as registering a library
Composable
Reusable logic with its own state per call
Bridge
Plugin exposes via provide, composable reads via useNuxtApp
Common mistake
Component specific logic wrongly implemented as a shared plugin