{metadata.image?.alt}

Astro With React, Svelte, and/or Vue?

Can you use React, Svelte, Vue.js, etc, together with Astro? Let's find out!
Mixing frameworks often raises complications or makes a solution pretty heavy. How does Astro handle this?

Introduction

So… how do Astro and other frameworks play along?

Very well, actually. Astro is framework-agnostic by default. If you don’t tell it otherwise, it will render plain HTML and CSS, relying on its Island Architecture to add interactivity only where and when it’s needed.

For this post, we’ll build a classic To-Do component in multiple frameworks and display them on the same page, while keeping the HTML consistent for easy comparison. The main goal is to demonstrate how you can mix frameworks when your use case requires it.

Adding React

At first, I worried that integrating React would require a full React project setup, but it’s much simpler than I expected.

First, install the necessary packages:

npm install @astrojs/react react react-dom

Then add React as an integration in astro.config.mjs:

import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
  integrations: [
    react(),
  ],
});

Now React components can be used inside Astro.

Tip: Give each framework its own folder to keep your project organized.

Here’s the classic ToDo component in React:

import { useState } from "react";

/* here's our little task */
type Todo = {
  text: string;
  done: boolean;
};

export default function Todo() {
  /* initialize the states */
  const [items, setItems] = useState<Todo[]>([]),
        [text, setText] = useState("");

  /* add a task to the collection */
  function addItem() {
    if (!text.trim()) return;
    setItems([...items, { text, done: false }]);
    setText("");
  }

  /* toggle state if a task is completed */
  function toggleItem(index: number) {
    setItems(items.map((item, i) =>
      i === index ? { ...item, done: !item.done } : item
    ));
  }

  /* let's render the component */
  return (
    <div className="react__todo todo__demo">
      <p>ToDo list:</p>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
        onKeyDown={e => e.key === "Enter" && addItem()}
        placeholder="Add todo and hit enter…"
        className="add__todo"
      />

      <ul className="todo__list">
        {items.map((item, i) => (
          <li 
            key={i} 
            onClick={() => toggleItem(i)}
            className="todo">
            <input
              type="checkbox"
              checked={item.done}
              className="todo__done"
            />
            <span 
              className="todo__task">
              {item.text}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

To render it in Astro:

  ---
  /* import the component in the frontmatter */
  import React__ToDo from '@components/React/Todo.tsx';
  ---
  /* render it */
  <section>
    ...
    /* side-note: no hydration directive set yet */
    <React__ToDo />
    ...
  </section>

Island Architecture:

The Island Architecture pattern means each interactive component (island) is isolated: it only brings its JavaScript and state when needed, instead of shipping all scripts for the entire page upfront.

In Astro, this works by rendering static HTML/CSS by default, and hydrating dynamic components like React only when necessary.
Using the client:visible directive, scripts load when the component enters the viewport, avoiding unnecessary data loading.

Here we have our component without the directive (so no interactivity):

React ToDo list:

    Let’s set client:visible and see what that does:

    <React__ToDo client:visible/>
    

    Now try it out and add a todo:

    React ToDo list:

      We use the client:visible here, but check the Astro docs for the other options for the one that suits your use case, like client:idle, client:load or client:only="react".

      client:visible results in the browser, loading the necessary scripts the moment the component enters the viewport. This reduces unnecessary data loading as long as the component is not visible (and usable).

      If you open your network-tab, you will find the scripts that were fetched. In this case, for a React component, the biggest one is almost 60KB.
      That file contains the React runtime, and it will be loaded just once.
      So if you have multiple components (or islands if you like), the runtime will not be loaded again, but ships with the first one.

      The screenshot of the Network Tab shows about 65kb using this React component

      Adding Svelte

      Install the Svelte packages:

      npm install @astrojs/svelte svelte
      

      Add Svelte to your integrations:

      import { defineConfig } from 'astro/config';
      import react from '@astrojs/react';
      import svelte from '@astrojs/svelte';
      
      export default defineConfig({
        integrations: [
          react(),
          svelte()
        ],
      });
      

      Now we are ready to create our Svelte Todo component:

      <script lang="ts">
        /* define our Todo type */
        type Todo = {
          text: string;
          done: boolean;
        };
      
        /* state */
        let items: Todo[] = [];
        let text = "";
      
        /* add a task to the collection */
        function addItem() {
          if (!text.trim()) return;
          items = [...items, { text, done: false }];
          text = "";
        }
      
        /* toggle completed state */
        function toggleItem(index: number) {
          items = items.map((item, i) =>
            i === index ? { ...item, done: !item.done } : item
          );
        }
      </script>
      
      <div class="svelte__todo todo__demo">
        <p>ToDo list:</p>
      
        <input
          bind:value={text}
          on:keydown={(e) => e.key === "Enter" && addItem()}
          placeholder="Add todo and hit enter…"
          class="add__todo"
        />
      
        <ul class="todo__list">
          {#each items as item, i}
            <li 
              class="todo"
              on:click={() => toggleItem(i)}
            >
              <input
                type="checkbox"
                checked={item.done}
                class="todo__done"
              />
              <span class="todo__task">
                {item.text}
              </span>
            </li>
          {/each}
        </ul>
      </div>
      

      Let’s test it:

      Svelte ToDo list:

        In the Network Tab we see what is loaded to hydrate the Svelte component.

        The screenshot of the Network Tab shows about 15kb using this Svelte component

        It’s not that big, nice!
        We just added two frameworks to our solution without hacks or conflicts, and performance-wise, it works beautifully.

        Shall we add just one more?

        Adding Vue 3

        Same routine. First, install the Vue packages (mind you… Astro requires Vue 3).

        npm install @astrojs/vue vue@3
        

        And add the integration:

        import { defineConfig } from 'astro/config';
        import react from '@astrojs/react';
        import svelte from '@astrojs/svelte';
        import vue from '@astrojs/vue';
        
        export default defineConfig({
          integrations: [
            react(),
            svelte(),
            vue()
          ],
        });
        

        Vue ToDo component:

        <script setup lang="ts">
        import { ref } from 'vue'
        
        type Todo = { text: string; done: boolean }
        
        const items = ref<Todo[]>([])
        const text = ref('')
        
        function addItem() {
          if (!text.value.trim()) return
          items.value.push({ text: text.value, done: false })
          text.value = ''
        }
        
        function toggleItem(index: number) {
          items.value[index].done = !items.value[index].done
        }
        </script>
        
        <template>
          <div class="vue__todo todo__demo">
            <p>Vue ToDo list:</p>
            <input
              v-model="text"
              @keydown.enter="addItem"
              placeholder="Add todo and hit enter…"
              class="add__todo"
            />
        
            <ul class="todo__list">
              <li 
                v-for="(item, i) in items" 
                :key="i"
                @click="toggleItem(i)"
                class="todo"
              >
                <input type="checkbox" v-model="item.done" class="todo__done" />
                <span class="todo__task">{{ item.text }}</span>
              </li>
            </ul>
          </div>
        </template>
        

        Let’s test it:

        Vue ToDo list:

          And check the Network Tab to see how the size of the loaded scripts.

          The screenshot of the Network Tab shows about 33kb using this Vue component

          Framework Comparison

          FrameworkComponentHydrationClient Scripts Size
          React<React__ToDo />client:visible~65 KB
          Svelte<Svelte__ToDo />client:visible~15 KB
          Vue 3<Vue__ToDo />client:visible~33 KB

          Notes:

          • client:visible means the component scripts are loaded only when the component enters the viewport.
          • Sizes are approximate and depend on your build and tree-shaking.
          • Astro itself (static content) remains minimal, even with multiple frameworks.

          Awesome, isn’t it? We have 3 components from 3 different frameworks playing along nicely on the same page, and all for about 110kb of scripts. Sweet!

          Conclusion

          Astro supports more of the major frameworks, but I think that these three are a nice example. I did try Angular (not officially supported), but immediately ran into some issues with CommonJS / ES Modules, so I aborted that plan.

          At the moment, Astro officially supports these frameworks:

          • React
          • Preact
          • Vue
          • Svelte
          • Solid

          I found it very easy to integrate them, but of course, a simple todo component doesn’t really challenge the integration limits.

          The performance of the rendered page is very nice. There is little to no extra code left behind, and the lazy-loading-like pattern prevents unnecessary data for the client.

          And since this article required me to set up my solution for React, Svelte, and Vue, I am going to play some more with it.

          Thank you for reading until the end!
          I need to work on a comments component, but for now, just use the contact form below if you spotted a mistake or want to leave some feedback.

          Form validation
          3 / 11

          Form validation with :user-valid

          This article might help you to get a better understanding of the :user-valid and :user-invalid pseudo-classes and what you can do with them.

          Looking for a Front-end Developer?

          I'm always on the lookout for a great project!

          Just a small note — I don’t reply to spam, cold sales messages, or royal inheritance proposals. Please redirect your riches to a local good cause instead.

          Success!

          Your message is sent and I will respond to you as soon as possible!

          I know, this message is slightly underwelming, but I will fix that soon.
          In the meantime, why not browse a bit more?
          Check out my articles maybe?

          READ MY ARTICLES

          Oh No!!

          Something went wrong I'm afraid...
          Your message was not sent...

          Maybe try again?

          Same Player Shoots Again...