
How to use Vue components in Astro?
To use Vue components in Astro, run npx astro add vue to install the official @astrojs/vue integration, then import any .vue single-file component into an .astro file and use it as a tag. Astro renders the component to HTML on the server and, by default, sends no JavaScript for it. Add a client directive such as client:load, client:idle or client:visible to hydrate it in the browser so its reactive state and event handlers work. Props are passed as attributes, default and named slots map to Vue's <slot> elements, and you can register Vue plugins for every island through the integration's appEntrypoint option.
The integration supports Vue 3, including <script setup>, the Composition API and TypeScript, and it's a good fit if your team already builds in Vue or you want to reuse existing components on a fast, mostly static site. In this article you'll learn how to install and configure Vue, write and hydrate a component, pass props and slots, register plugins and global properties, use the integration's options, and avoid the common pitfalls.
Installing the Vue Integration
Run the astro add command from your project folder:
npx astro add vue
It installs @astrojs/vue and vue, then updates your Astro config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
export default defineConfig({
integrations: [vue()],
});
To install manually instead, run npm install @astrojs/vue vue and add the integration yourself. At the time of writing the latest versions are @astrojs/vue 7.1.2 and Vue 3.5.
Writing a Vue Component
Vue components in Astro are standard single-file components. This counter uses <script setup> with TypeScript:
<!-- src/components/VueCounter.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ label: string; start?: number }>();
const count = ref(props.start ?? 0);
</script>
<template>
<button type="button" @click="count++">{{ label }}: {{ count }}</button>
</template>
Import it in an Astro page and pass props as attributes:
---
// src/pages/index.astro
import VueCounter from '../components/VueCounter.vue';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Vue in Astro</title>
</head>
<body>
<VueCounter label="Clicks" start={3} />
</body>
</html>
Astro runs the component on the server with Vue's server renderer and outputs <button type="button">Clicks: 3</button>. With no directive, that's all the browser receives. The @click handler is never attached, because no JavaScript was sent.
Notice that the import includes the .vue extension. Astro picks the renderer from the file extension, so .vue files always go to the Vue integration.
Hydrating a Vue Component
To make the counter work in the browser, add a client directive:
<VueCounter label="Clicks" start={3} client:visible />
Here's what the built HTML looked like for that line in my test project, with the long attribute values shortened:
<astro-island ... component-url="/_astro/VueCounter.BfY-iG3v.js" ... props="{"label":[0,"Clicks"],"start":[0,3]}" ssr client="visible" ...><button type="button">Clicks: 3</button><!--astro:end--></astro-island>
The button is already there as HTML. When it scrolls into view, Astro loads the component and Vue's runtime, creates a Vue app for that island and hydrates it against the existing markup. Choosing between the directives is covered in what client:load, client:idle and client:visible mean.
Vue's runtime is downloaded once per page and shared by all Vue islands on it. In my test build, the Vue runtime and Astro's Vue client renderer together came to about 29 KB gzipped.
Passing Props
Props work in both server rendering and hydration. For hydrated components, Astro serialises them into the props attribute you saw above, so they must be serialisable: plain objects, strings, numbers, arrays, Map, Set, Date, RegExp, BigInt, URL, typed arrays and Infinity are all supported.
Functions can't be serialised, so you can't pass an event handler from Astro into a hydrated Vue component. In Vue terms, that means @custom-event listeners written in an .astro file won't work. Handle events inside the component, or emit them to a parent Vue component that lives in the same island.
Using Slots
Children passed from Astro arrive in Vue's default slot, and elements with a slot attribute fill named slots. Unlike React, kebab-case slot names are kept as they are, which matches how Vue already names slots:
<!-- src/components/VueCard.vue -->
<template>
<article class="card">
<header><slot name="title" /></header>
<div class="card-body"><slot /></div>
<footer><slot name="footer-links" /></footer>
</article>
</template>
---
// src/pages/index.astro
import VueCard from '../components/VueCard.vue';
---
<VueCard>
<h2 slot="title">Menu</h2>
<p>Default slot content.</p>
<ul slot="footer-links">
<li><a href="/about">About</a></li>
</ul>
</VueCard>
The built HTML contained Vue's fragment markers around each slot:
<article class="card"><header><!--[--><h2>Menu</h2><!--]--></header><div class="card-body"><!--[--><p>Default slot content.</p><!--]--></div><footer><!--[--><ul><li><a href="/about">About</a></li></ul><!--]--></footer></article>
Slots are also the way to put Astro components inside Vue components. A .vue file can't import an .astro file, but an Astro page can pass one as slot content. The content is rendered by Astro on the server and handed to Vue as HTML.
Scoped slots, where the child component passes data back up to the slot content, don't work from an Astro file, because Astro's slot content is rendered before Vue runs. Use them inside Vue component trees instead.
Registering Plugins with appEntrypoint
In a normal Vue app you call app.use() on the instance returned by createApp(). In Astro, the integration creates the app for each island, so you hook in through the appEntrypoint option. Point it at a file whose default export receives the Vue App instance:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
export default defineConfig({
integrations: [vue({ appEntrypoint: '/src/pages/_app' })],
});
// src/pages/_app.ts
import type { App } from 'vue';
export default (app: App) => {
app.config.globalProperties.$formatPrice = (value: number) =>
new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(value);
};
The leading underscore keeps _app.ts from becoming a route. Any component can now use the global property:
<!-- src/components/VuePrice.vue -->
<script setup lang="ts">
defineProps<{ amount: number }>();
</script>
<template>
<p>{{ $formatPrice(amount) }}</p>
</template>
I rendered <VuePrice amount={19.5} client:load /> and it showed £19.50 both in the server HTML and after hydration, which confirms the entrypoint runs in both environments. The same function is where you'd call app.use() for plugins such as an i18n library.
Other Integration Options
Options you pass to vue() that aren't Astro-specific go straight to @vitejs/plugin-vue, so you can configure the Vue compiler there. A common example is telling Vue about custom elements so it doesn't try to resolve them as components:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
export default defineConfig({
integrations: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('ion-'),
},
},
}),
],
});
The integration also has two options of its own:
- jsx: set
jsx: trueto support Vue components written in JSX, or pass an object of options for@vitejs/plugin-vue-jsx. - devtools: set
devtools: trueto enable Vue DevTools during development, or pass an object of options supported by the Vue DevTools Vite plugin.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
export default defineConfig({
integrations: [vue({ jsx: true, devtools: true })],
});
Passing Server Data into Vue Islands
A common pattern is to load data in an Astro page's frontmatter, where you can query a CMS, read a content collection or call an API at build time, and hand the result to a Vue component as props. The component then only deals with presentation and interaction:
---
// src/pages/products.astro
import ProductFilter from '../components/ProductFilter.vue';
const response = await fetch('https://example.com/api/products.json');
const products: { id: string; name: string; price: number }[] = await response.json();
---
<ProductFilter products={products} client:visible />
Inside the component, defineProps<{ products: { id: string; name: string; price: number }[] }>() receives the array, and Vue's computed() can filter it as the visitor types. The data is serialised into the island's props attribute, so keep it to the fields the component actually needs. Every extra field is extra HTML on every page load.
The same approach works for any data source. The fetch runs once at build time for a static page, or once per request for an on-demand page, and the browser never needs to call your API to render the first view.
Common Pitfalls
Each Island Is a Separate Vue App
Two hydrated Vue components on a page are two Vue applications. provide() and inject() don't cross between them, and a state object created inside one component isn't seen by the other. If several islands need the same data, put it in a module-level store. Vue's own reactive() exported from a shared file works between Vue islands, and Nano Stores with @nanostores/vue works across frameworks too, as shown in sharing state between islands.
Browser APIs During Setup
Code in <script setup> runs on the server during rendering. Reading window or localStorage there will fail. Move it into onMounted(), which only runs in the browser, or render the component with client:only="vue".
Mixing Vue with Other Frameworks
Vue can sit alongside React, Svelte or any other integration on the same page without extra configuration, because .vue files are unambiguous. The one thing to watch is TypeScript JSX settings if you also enable Vue JSX alongside React. The details are in mixing multiple UI frameworks in one project.
FAQ: Vue Components in Astro
No. The current @astrojs/vue integration supports Vue 3 single-file components, including script setup and the Composition API.
Pinia is a Vue plugin, so it would be registered through the appEntrypoint function with app.use(). Keep in mind that each island is its own app, so test that state is shared between islands the way you expect before relying on it.
Astro handles routing with files in src/pages, so you don't need Vue Router for page navigation. If you embed a full Vue single-page app in one island, that app can still use its own router internally.
The component probably has no client directive. Without one, Astro renders it to static HTML and sends no JavaScript, so no event listeners are attached. Add client:load, client:idle or client:visible.
Yes, as slot content from an .astro file. You can't import an .astro file inside a .vue file, but Astro can render one and pass it to a default or named slot.
Yes. Styles in a Vue component's style block, including scoped styles, are processed by the Vue plugin and bundled with the page as usual.
Conclusion
Adding Vue to Astro is a single astro add vue command, and your single-file components work without changes. Astro renders them to HTML on the server, a client directive turns any of them into an interactive island, and props and slots behave much as they do in Vue, with kebab-case slot names preserved. The appEntrypoint option gives you a place to register plugins and global properties for every island.
The main adjustment is remembering that each hydrated component is its own Vue app. Keep shared state in a store outside the components, keep browser-only code in onMounted(), and only hydrate components that need to react to the user. That keeps Vue's ergonomics while letting Astro deliver mostly static, fast pages.


