This site has the @astrojs/vue integration installed, so any .vue single-file component can be used in an MDX post or an .astro page. This post covers why that’s worth having on a static blog, what actually happens to a Vue component, and the rules for using one.
An interactive counter
The component lives in src/components/Counter.vue. This post imports it and renders it like any other component:
import Counter from "@/components/Counter.vue";
<Counter client:visible>A Vue component, hydrated when it scrolls into view.</Counter>
Why add Vue to an Astro site?
Astro on its own is very good at one thing: turning components into static HTML at build time. An .astro component runs once, during the build, and never again. That’s why this site is fast and costs nothing to host.
For interactivity, Astro gives you a plain <script> tag. That’s enough for small things, like the theme toggle or the copy buttons on code blocks. It gets painful once a widget has real state. With plain DOM code you find elements, set their text, add and remove classes, and keep every part of the page consistent with every change by hand.
Vue takes that job over. You declare the state, describe how the page depends on it, and Vue keeps the two in sync. The Stream pipeline playground is a good example:
- Its only state is the source text, the list of operations and the terminal operation.
- The per-step output, the result and the generated Java code are all
computedfrom that state. - Change any input and everything that depends on it updates; nothing is synced by hand.
Vue also brings the building blocks such widgets need:
v-modelfor two-way binding of inputs.v-forwith keys for lists that keep their identity when reordered.<TransitionGroup>for animating items as they’re added, removed and moved.- Scoped CSS and typed props, with template, logic and style in one
.vuefile.
On top of that comes the Vue ecosystem: chart libraries, form validation, Pinia for shared state, and Vue’s devtools.
What runs at build time, and what runs in the browser
A Vue component always runs at build time. Whether it also runs in the browser depends on a client:* directive.
Without a directive, Astro runs the component once in Node during the build and keeps only the HTML it produces. No Vue code reaches the browser. The counter below starts at 5, but its buttons do nothing, because nothing is listening for clicks:
With a directive, the browser gets two things:
- The same HTML, generated at build time, so the component appears immediately, even before any JavaScript runs.
- The component’s JavaScript plus the Vue runtime. When the directive’s condition is met, Vue starts up in the browser and attaches itself to the existing HTML. This step is called hydration. From then on, clicks and typing update the component like in any Vue app.
The directive decides when hydration happens:
| Directive | Hydrates |
|---|---|
client:load | Immediately on page load |
client:idle | Once the browser is idle |
client:visible | When the component scrolls into view |
client:media | When a CSS media query matches |
client:only="vue" | In the browser only, skipping server rendering |
This is Astro’s islands model: the page is static HTML, with a few interactive islands where you asked for them. Everything outside the islands stays plain HTML.
Why this works on GitHub Pages
GitHub Pages can only serve files; it can’t run code for each request. That’s fine, because nothing here needs a server:
- The build step, which renders every component to HTML, runs in GitHub Actions on each push.
- Hydration happens in the reader’s browser, using ordinary JavaScript files from
/_astro/. To GitHub Pages they’re no different from an image or a stylesheet.
What wouldn’t work here is anything that needs a server at request time: rendering pages on demand, API routes, or keeping secrets. A Vue component can still call a public API from the browser.
What it costs
Measured from this site’s build, gzipped:
| Download | Size |
|---|---|
| Vue runtime, shared by all islands | about 33 KB |
| Counter | under 1 KB |
| Stream playground / collections explorer | 3–4 KB each |
| Regex tester | about 6 KB |
The runtime is downloaded once per page, and only by pages with at least one hydrated component. Every other page on this site loads no Vue code at all.
When not to use Vue
- Small behaviors, like a copy button, a toggle or a single show/hide: a few lines in an Astro
<script>do the job without loading the 33 KB runtime. - Content that doesn’t change after the page loads, like tables and diagrams: write it in Markdown or an
.astrocomponent, rendered at build time. - Anything that needs to store data, like comments, view counts or a newsletter signup: that needs an outside service, whatever renders the form.
Why the post has to be MDX
Vue components only work in .mdx posts, not .md or .adoc ones.
A .md file is text that becomes HTML. There’s no step that runs code, and no way to import anything. <Counter /> in a Markdown file would just be an unknown HTML tag that renders nothing. AsciiDoc works the same way here: Asciidoctor turns the file into an HTML string, which goes into the page as-is.
MDX is Markdown plus JSX. An .mdx file is compiled into a JavaScript module, the same kind as an .astro component, which gives it the two things a component needs:
import Counter from "@/components/Counter.vue"tells Astro which file the component comes from.<Counter client:visible />is a real component call. Astro renders it with Vue and, because of the directive, ships its JavaScript.
So only posts that embed a component need to be .mdx; everything else can stay Markdown or AsciiDoc.
Styling
Components use the site’s Tailwind classes and theme colors (bg-accent, border-border, text-muted-foreground), so they follow the light/dark toggle automatically.
Inside a post, give the component’s outer element not-prose text-foreground:
not-proseturns off the typography plugin’s styles for headings, lists, tables and so on.text-foregroundis needed as well. Post pages set a default text color, and only standard tags like<p>and<td>override it for dark mode. Without it, text in the component’s own markup is nearly invisible in dark mode.
More examples
- Interactive Java Examples with Vue: a Stream pipeline playground and a Java collections explorer.
- A Java Regex Tester: a
java.util.regextester, plus code tabs that stay in sync across the page.