Emanuel Suriano Hi ๐Ÿ‘‹ I build stuff with JavaScript ๐Ÿ’ป Once a month I write an article โœ๏ธ and sometimes I give talks ๐Ÿ’ฌ

Building a multi-framework dashboard with Astro

17 min read 5012 105

Building a multi-framework dashboard with Astro

In this article, we will be exploring the process of building a website in Astro using components from three of the most popular frameworks: React, Svelte, and Vue.js.

Each of these frameworks has its strengths and weaknesses, and in this article, we will provide a step-by-step guide to building a website using each of them together. This means that I can take the best of each community and put it into the project!

Jump ahead:

Project characteristics

For this demo, we are going to use the following libraries to build the dashboard:

I also installed Tailwind CSS to create most of the layout for the page, with the prefix option of tw- (this is to prevent clashing classes between Tailwind and Vuetify).

Our end goal is to build the following dashboard:

Our final multi-framework dashboard

Something important to highlight is that all of our components are connected, and an interaction in one can trigger a change in the other. This is possible by using nanostores (we are going to cover it later in the article).

Components are connected: interacting with the React category chart displays changes in the Svelte visual diagram
Components are connected: interacting with the Svelte category chart displays changes in the React visual diagram

Finally, for demo purposes, I built a framework highlighter to visually display the UI framework (React, Vue, or Svelte) responsible for rendering a component. This function can be activated by clicking the blue eye icon at the bottom of the page.

The UI framework highlighter indicates which components were built with which framework

You can also check out the deployed application here.

Why would you want to use multiple frameworks in one project?

Building a multi-framework application using Astro can be a powerful and efficient way to create complex and versatile software. By combining the strengths of different frameworks, developers can create a more robust and flexible application that can adapt to changing requirements and meet the diverse needs of users. Using Astro, developers can easily integrate multiple frameworks, allowing them to take advantage of the best features of each one and create a truly exceptional application.

In addition to its ability to combine multiple frameworks, Astro is also great for migrations. As software systems and requirements evolve, it may become necessary to migrate to a new framework or set of frameworks. With Astro, this process can be streamlined and made more efficient, allowing developers to quickly and easily move their applications to new frameworks without significant downtime or disruptions. This can save time, effort, and resources, making Astro a valuable tool for anyone looking to migrate their application to new frameworks.

Setting up a fresh Astro project

The easiest way to create a new project with Astro is by using their CLI, create-astro, which comes with maybe one of the friendliest coding assistants ever: Houston!

# create a new project with npm
> yarn create astro

โ•ญโ”€โ”€โ”€โ”€โ”€โ•ฎ  Houston:
โ”‚ โ—  โ—ก โ—   Let's make the web weird!
โ•ฐโ”€โ”€โ”€โ”€โ”€โ•ฏ

 astro   v1.6.15 Launch sequence initiated.

โœ” Where would you like to create your new project? โ€ฆ astro-dashboard
โœ” How would you like to setup your new project? โ€บ a few best practices (recommended)
โœ” Template copied!
โœ” Would you like to install yarn dependencies? (recommended) โ€ฆ yes
โœ” Packages installed!
โœ” Would you like to initialize a new git repository? (optional) โ€ฆ yes
โœ” Git repository created!
โœ” How would you like to setup TypeScript? โ€บ Strict
โœ” TypeScript settings applied!

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./astro-dashboard
         Run yarn dev to start the dev server. CTRL+C to stop.
         Add frameworks like react or tailwind using astro add.

         Stuck? Join us at https://astro.build/chat

โ•ญโ”€โ”€โ”€โ”€โ”€โ•ฎ  Houston:
โ”‚ โ—  โ—ก โ—   Good luck out there, astronaut!
โ•ฐโ”€โ”€โ”€โ”€โ”€โ•ฏ

After that, you should end up with a folder that has a structure similar to this:

/astro-dashboard
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ astro.config.mjs
โ”œโ”€โ”€ output.txt
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ public
|  โ””โ”€โ”€ favicon.svg
โ”œโ”€โ”€ node_modules # sub folders ignored to keep the directory tree short
โ”œโ”€โ”€ src
|  โ”œโ”€โ”€ components
|  |  โ””โ”€โ”€ Card.astro
|  โ”œโ”€โ”€ env.d.ts
|  โ”œโ”€โ”€ layouts
|  |  โ””โ”€โ”€ Layout.astro
|  โ””โ”€โ”€ pages
|     โ””โ”€โ”€ index.astro
โ”œโ”€โ”€ tsconfig.json
โ””โ”€โ”€ yarn.lock

Let’s start our development server by running:

> yarn dev

yarn run v1.22.19
$ astro dev
  ๐Ÿš€  astro  v1.6.15 started in 57ms

  โ”ƒ Local    http://127.0.0.1:3000/
  โ”ƒ Network  use --host to expose

After opening the localhost URL, you should see the following screen in your browser:
The Astro start page

Adding the UI frameworks

The official Astro documentation has a summary of all their official integrations, covering the topics of UI frameworks, SSR adapters, and others, such as Tailwind.

The Astro team made it simple for everybody and includes the command astro add in all new projects, which we can use to install any of the official integrations. We simply need to execute:

> yarn astro add react vue svelte tailwind

โœ” Resolving packages...

  Astro will run the following command:
  If you skip this step, you can always run it yourself later

โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ yarn add @astrojs/react @astrojs/svelte @astrojs/tailwind @astrojs/vue @types/[email protected]^18.0.6     โ”‚
โ”‚ @types/[email protected]^18.0.21 [email protected]^18.0.0 [email protected]^18.0.0 [email protected]^3.46.4 [email protected]^3.0.24            โ”‚
โ”‚ [email protected]^3.2.30                                                                                         โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

โœ” Continue? โ€ฆ yes
โœ” Installing dependencies...

  Astro will generate a minimal ./tailwind.config.cjs file.

โœ” Continue? โ€ฆ yes

  Astro will make the following changes to your config file:

 โ•ญ astro.config.mjs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
 โ”‚ import { defineConfig } from 'astro/config';            โ”‚
 โ”‚                                                         โ”‚
 โ”‚ // https://astro.build/config                           โ”‚
 โ”‚ import react from "@astrojs/react";                     โ”‚
 โ”‚ import vue from "@astrojs/vue";                         โ”‚
 โ”‚ import svelte from "@astrojs/svelte";                   โ”‚
 โ”‚ import tailwind from "@astrojs/tailwind";               โ”‚
 โ”‚                                                         โ”‚
 โ”‚ // https://astro.build/config                           โ”‚
 โ”‚ export default defineConfig({                           โ”‚
 โ”‚   integrations: [react(), vue(), svelte(), tailwind()]  โ”‚
 โ”‚ });                                                     โ”‚
 โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

โœ” Continue? โ€ฆ yes

   success  Added the following integrations to your project:
  - @astrojs/react
  - @astrojs/vue
  - @astrojs/svelte
  - @astrojs/tailwind

  Astro will make the following changes to your tsconfig.json:

 โ•ญ tsconfig.json โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
 โ”‚ {                                       โ”‚
 โ”‚   "extends": "astro/tsconfigs/strict",  โ”‚
 โ”‚   "compilerOptions": {                  โ”‚
 โ”‚     "jsx": "react-jsx",                 โ”‚
 โ”‚     "jsxImportSource": "react"          โ”‚
 โ”‚   }                                     โ”‚
 โ”‚ }                                       โ”‚
 โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

โœ” Continue? โ€ฆ yes

   success  Successfully updated TypeScript settings

As you can see, Astro took over all the manual tasks of installing the integrations, adding them to our Astro configuration file, and even updating our tsconfig.json to make React work nicely with Typescript. Great job, Houston!

Setting up our layout

Let’s start by setting up our Layout for the page, which consists of a Header, a Footer, and a container for our page content. To avoid several changes when we introduce vuetify, itโ€™s better to change the prefix inside our tailwind.config.cjs file, like so:

# tailwind.config.cjs
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
  theme: {
    extend: {},
  },
  plugins: [],
+  prefix: 'tw-',
};

Let’s apply the modification to our Layout:

// src/layouts/Layout.astro
---
type Props = {
  title: string;
};

const { title } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
  </head>
  <body>
    <div class="tw-flex tw-h-screen tw-overflow-hidden">
      <main
        class="tw-relative tw-bg-white tw-flex tw-flex-col tw-flex-1 tw-overflow-y-auto tw-overflow-x-hidden"
      >
        <!-- Header -->
        <nav class="tw-bg-white tw-shadow-sm tw-px-2 sm:tw-px-4 tw-py-2.5">
          <div
            class="container tw-flex tw-flex-wrap tw-justify-between tw-items-center tw-mx-auto"
          >
            <a href="/" class="tw-flex tw-items-center">
              <img
                src="/favicon.svg"
                class="tw-mr-3 tw-h-6 sm:tw-h-9"
                alt="Astro Logo"
              />
              <span
                class="tw-self-center tw-text-xl tw-font-semibold tw-whitespace-nowrap"
                >{title}
              </span>
            </a>
          </div>
        </nav>

        <!-- Content container -->
        <div class="tw-container tw-mx-auto tw-my-10 tw-flex-grow">
          <slot />
        </div>

        <!-- Footer -->
        <footer
          class="tw-p-4 tw-bg-white tw-rounded-lg tw-shadow md:tw-flex md:tw-items-center md:tw-justify-between md:tw-p-6"
        >
          <span class="tw-text-sm tw-text-gray-500 sm:tw-text-center">
            Built with
            <a href="https://astro.build/" class="hover:tw-underline">Astro</a>
          </span>
        </footer>
      </main>
    </div>
  </body>
</html>

Without making any changes to our initial page (src/pages/index.astro) and applying these changes, you should see the following screen:
Our final layout setup in Astro

Fetching the dashboard data

For this demo, I decided to use the Products list endpoint from https://dummyjson.com/, which provides several public REST APIs that we can use to simulate getting data from our servers. When we call the API, the output has the following structure:

{
  "products": [
    {
      "id": 1,
      "title": "iPhone 9",
      "description": "An apple mobile which is nothing like apple",
      "price": 549,
      "discountPercentage": 12.96,
      "rating": 4.69,
      "stock": 94,
      "brand": "Apple",
      "category": "smartphones",
      "thumbnail": "...",
      "images": ["...", "...", "..."]
    },
    {...},
    {...},
    {...}
    // 30 items
  ],

  "total": 100,
  "skip": 0,
  "limit": 30
}

Given that we are working on a TypeScript project, it’s always recommended to type our API responses. For that, we can use the following type for this response:

// src/types.ts
export type Product = {
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
};

To fetch data using Astro, we can use the fetch function to call the endpoint before rendering our component, with the added benefit that we don’t need to handle loading states in our application. This way of loading data is similar to how data is loaded on static sites, where the endpoints are called on at build time.

At the same time, Astro also supports server-side rendering. It’s always important to understand what kind of application is better to implement given your needs.

Since our data source can be considered static โ€” the list of products doesn’t change over time โ€” running our queries is the best fit to improve the performance and UX of the application. Let’s make the following modification to our initial page:

// src/pages/index.astro
---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
+ import type { Product } from '../types';

// fetch data
+ const products: Product[] = await fetch('https://dummyjson.com/products')
+   .then((res) => res.json())
+   .then((res) => res.products);
---

<Layout title="Welcome to Astro.">
  <main>
    <h1>Welcome to <span class="text-gradient">Astro</span></h1>
...

Integrating Svelte components in an Astro app

Time to render our data! Letโ€™s revisit the goal of this article: we want to use multiple UI frameworks in the same project. Remember, we already installed all the UI integrations into the project, so the only part missing is adding our components. Let’s go one by one, starting with Svelte!

Integrating a Table component

As I mentioned at the beginning of this article, weโ€™re using Svelte Material UI. To install it in our project, we have to first add it to our dependencies:

> yarn add svelte-material-ui

After that, we have to import the necessary CSS file for the components. The best place for those is in our Layout component because it will be used inside every screen in our application.

// src/layouts/Layout.astro
---
+ import 'svelte-material-ui/bare.css';

type Props = {
  title: string;
};

const { title } = Astro.props;
---

<!DOCTYPE html>
...

When dealing with npm dependencies in Astro, which is built on top of Vite, that are not previously compiled (converted into JavaScript), we have to specify Vite to the property ssr.noExternal in our Astro configuration, inside the vite attribute. If you want to read more about Externals, please refer to the official documentation.

// astro.config.mjs
import { defineConfig } from 'astro/config';

// https://astro.build/config
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';
import tailwind from '@astrojs/tailwind';

// https://astro.build/config
export default defineConfig({
  integrations: [react(), vue(), svelte(), tailwind()],
+  vite: {
+    ssr: {
+      noExternal: ['@smui/**/*'],
+    },
+  },
});

Next, let’s build our first Svelte component to show a list of products inside a table, which is going to be called ProductTable. Luckily, smui provides a Table component called Data Table. Below is the full code of the component:

// src/components/ProductTable.svelte
<script lang="ts">
  import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
  import type { Product } from 'types';

  export let products: Product[];
</script>

<DataTable style="width: 100%;">
  <Head>
    <Row>
      <Cell style="width: 100%;">Title</Cell>
      <Cell>Category</Cell>
      <Cell>Description</Cell>
      <Cell>Price</Cell>
      <Cell>Stock</Cell>
    </Row>
  </Head>
  <Body>
    {#each products as item (item.id)}
      <Row>
        <Cell>{item.title}</Cell>
        <Cell>{item.category}</Cell>
        <Cell>{item.description}</Cell>
        <Cell numeric>{item.price}</Cell>
        <Cell numeric>{item.stock}</Cell>
      </Row>
    {/each}
  </Body>
</DataTable>

Now, let’s render it inside our main page. I got rid of the Card components that were rendered in the template project.

// src/page/index.astro
---
import Layout from '../layouts/Layout.astro';
import ProductTable from '../components/ProductTable.svelte';

import type { Product } from '../types';

// fetch data
const products: Product[] = await fetch('https://dummyjson.com/products')
  .then((res) => res.json())
  .then((res) => res.products);
---

<Layout title="Welcome to Astro.">
  <ProductTable products={products} />
</Layout>

Our application should look like this:
Our integrated Svelte table component

Integrating Svelte category tags

One key feature of this dashboard is the ability to see all the existing categories and mark them as visible or hidden. To build this component, we are going to use the component Chips from smui. This is a possible implementation of the component:

// src/component/CategoryTags.astro
<script lang="ts">
  import Chip, { Set, Text } from '@smui/chips';
  import type { Product } from '../types';

  export let products: Product[];

  let categories = products
    .map((product) => product.category)
    .filter((val, i, arr) => arr.indexOf(val) === i);

  let selected = Array.from(categories);
</script>

<h1>Categories</h1>

<Set chips={categories} let:chip filter bind:selected>
  <Chip {chip} touch>
    <Text>{chip}</Text>
  </Chip>
</Set>

Given that this component is interactive, we need to specify a client directive from Astro to load the JavaScript alongside it โ€” otherwise, nothing will happen when clicking the categories. Let’s add the component to our main page with the client:load directive.

// src/pages/index.astro
---
import Layout from '../layouts/Layout.astro';
import ProductTable from '../components/ProductTable.svelte';
+ import CategoryTags from '../components/CategoryTags.svelte';

import type { Product } from '../types';

// fetch data
const products: Product[] = await fetch('https://dummyjson.com/products')
  .then((res) => res.json())
  .then((res) => res.products);
---

<Layout title="Welcome to Astro.">
+  <CategoryTags products={products} client:load />
  <ProductTable products={products} client:load />
</Layout>

The application is looking now quite similar to our dashboard!



Svelte category tags are integrated into our dashboard

Data synchronization and state management with nanostores

Have you spotted any bugs in the previous screen? The categories that are supposed to be hidden are still visible in our table. The reason for that is that we arenโ€™t communicating data between our ProductTable and the CategoryTags. Time to talk about state management!

How do we share information between my components, given that each library has different ways of achieving that? We have a few different options:

  • React has the Context API, with a Provider / Consumer pattern
  • Vue provides a reactive API, following the conventions of Actions --> State --> View in a similar style to Redux
  • Svelte recommends using Stores, providing readable, writable, and derived containers for storing data

All of these are valid ways of fixing the state management issue, but it’s clear that we need to find a common solution that works for all the UI libraries. This is where nanostores comes into play! The description they provide on their GitHub page is simply perfect:

A tiny state manager for React, React Native, Preact, Vue, Svelte, and vanilla JS. It uses many atomic stores and direct manipulation.

The API is simple as:

import { atom } from 'nanostores';

// definition of state for primitive values
export const counter = atom(0);

// read and write state
counter.set(counter.get() + 1);

// definition of state for objects
export const car = map({
  brand: 'Tesla',
  km: 0,
});

// update property inside object
car.setKey('km', car.get().km + 1000);

nanostores provides many APIs to have more control over state updates and notifications, so I highly recommend checking their documentation in case you want to dig deeper.

Nevertheless, an important part to cover is the integration with the different UI libraries. For each framework, they provide a separate package that will follows the packageโ€™s coding standard:

  • React (@nanostores/react): provides a useStore() Hook to get the storeโ€™s value and re-render the component on storeโ€™s changes
  • Vue (@nanostores/vue): provides a useStore() composable function to get the storeโ€™s value and re-render the component on storeโ€™s changes
  • Svelte: nanostores implements Svelte’s store contract; we just need to put $ before the store variable to get the value and subscribe for changes

To add nanostores into our project, we just need to install the main nanostores dependency and the integrations that we need (in our case, one each for vue and react).

> yarn add nanostores @nanostores/react @nanostores/vue

It’s also recommended to store our store in a shared location where all the components can access it. For our categories scenario, we need a simple array of strings where we can add or remove the categories to hide them.

// src/state.ts
import { atom } from 'nanostores';

export const hiddenCategories = atom<string[]>([]);

Let’s connect our two “isolated” components. The CategoryTags component has to be able to read and write from hiddenCategories.

// src/components/CategoryTags.svelte
<script lang="ts">
  import Chip, { Set, Text } from '@smui/chips';
  import type { Product } from '../types';
+  import { hiddenCategories } from '../state';

  export let products: Product[];

  let categories = products
    .map((product) => product.category)
    .filter((val, i, arr) => arr.indexOf(val) === i);

  let selected = Array.from(categories);

+  function updateSelection() {
+    hiddenCategories.set(
+      categories.filter((category) => !selected.includes(category)),
+    );
+  }
</script>

<h1>Categories</h1>

<Set
  chips={categories}
  let:chip
  filter
  bind:selected
+  on:click={updateSelection}
>
  <Chip {chip} touch>
    <Text>{chip}</Text>
  </Chip>
</Set>

Our ProductTable has only to read from hiddenCategories, and with that, filter out the product that belongs to the categories in our state.

// src/components/ProductTable.svelte
<script lang="ts">
  import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
  import type { Product } from '../types';
+  import { hiddenCategories } from '../state';

  export let products: Product[];
</script>

<DataTable style="width: 100%;">
  <Head>
    <Row>
      <Cell style="width: 100%;">Title</Cell>
      <Cell>Category</Cell>
      <Cell>Description</Cell>
      <Cell>Price</Cell>
      <Cell>Stock</Cell>
    </Row>
  </Head>
  <Body>
+    {#each products.filter((product) => !$hiddenCategories.includes(product.category)) as item (item.id)}
      <Row>
        <Cell>{item.title}</Cell>
        <Cell>{item.category}</Cell>
        <Cell>{item.description}</Cell>
        <Cell numeric>{item.price}</Cell>
        <Cell numeric>{item.stock}</Cell>
      </Row>
    {/each}
  </Body>
</DataTable>

After removing the Smartphones and Laptops categories from the CategoryTags, we can see they don’t exist any more in the ProductTable:
Smartphones and laptops are removed from our categories

Vue integration: Overview

For Vue, I decided to use the most popular component framework option in the ecosystem: Vuetify. The installation can be a bit tricky for alternative projects (like this one!), so I hope to make it as clear as possible. Let’s start by installing the necessary dependencies (be aware that only the latest version, v3, is compatible with Vite):

> yarn add [email protected]^3.0.5

Similar to what we did for smui, we need to import the component library styles inside our Layout:

---
import 'svelte-material-ui/bare.css';
+ import 'vuetify/styles';

type Props = {
  title: string;
};

const { title } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
  <head>
...

Next, we need to hook vuetify into our vue application. This was impossible in Astro just a few versions ago, until the introduction of the _app file inside the pages folder, which grants access to the App instance of vue.

//src/pages/_app.ts
import type { App } from 'vue';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';

export default (app: App) => {
  const vuetify = createVuetify({
    ssr: true,
    components,
    directives,
  });

  app.use(vuetify);
};

A couple of modifications in our astro.config.mjs file: We need to mark the dependency as noExternal and link the Astro Vue integration with the entry point that we’ve just defined.

// astro.config.mjs
import { defineConfig } from 'astro/config';

// https://astro.build/config
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';
import tailwind from '@astrojs/tailwind';

// https://astro.build/config
export default defineConfig({
  integrations: [
    react(),
+    vue({ appEntrypoint: '/src/pages/_app' }),
    svelte(),
    tailwind(),
  ],
  vite: {
    ssr: {
+      noExternal: ['@smui/**/*', 'vuetify'],
    },
  },
});

Now, we can implement our Summary component, which is going to give us a glance at the Total Stock and Total brands categories and the selected products inside them. This sounds perfect for the v-card component!

// src/components/Overview.vue
<script setup lang="ts">
import type { Product } from 'types';
import { hiddenCategories } from '../state';
import { useStore } from '@nanostores/vue';

type Props = {
  products: Product[];
};

const { products } = defineProps<Props>();
const $hiddenCategories = useStore(hiddenCategories);
</script>

<template>
  <h1>Overview</h1>

  <v-container>
    <v-row dense>
      <v-col cols="12">
        <v-card>
          <v-card-text>
            <div>Total stock</div>
            <p class="text-h4 text--primary">
              <!-- Calculating stock with the value from store -->
              {{
                products
                  .filter(
                    (product) => !$hiddenCategories.includes(product.category),
                  )
                  .reduce((acc, curr) => acc + curr.stock, 0)
              }}
            </p>
          </v-card-text>
        </v-card>
      </v-col>

      <v-col cols="12">
        <v-card>
          <v-card-text>
            <div>Total brands</div>
            <p class="text-h4 text--primary">
              <!-- Calculating brand amount with the value from store -->
              {{
                products
                  .filter(
                    (product) => !$hiddenCategories.includes(product.category),
                  )
                  .filter((product, i, array) => array.indexOf(product) === i)
                  .length
              }}
            </p>
          </v-card-text>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

Don’t forget to add it to the main page as well!

// src/pages/index.astro
---
import Layout from '../layouts/Layout.astro';
import type { Product } from '../types';

import ProductTable from '../components/ProductTable.svelte';
import CategoryTags from '../components/CategoryTags.svelte';
import Overview from '../components/Overview.vue';

// fetch data
const products: Product[] = await fetch('https://dummyjson.com/products')
  .then((res) => res.json())
  .then((res) => res.products);
---

<Layout title="Welcome to Astro.">
  <CategoryTags products={products} client:load />
+ <Overview products={products} client:load />
  <ProductTable products={products} client:load />
</Layout>

Hereโ€™s what our dashboard looks like now:
Our summary of total brands and total stock is added to the dashboard

React integration: CategoryChart

This is the last integration of this article, and maybe the most fun! A dashboard cannot be considered complete unless we show a chart or diagram to visually display information. For this part of the demo, weโ€™ll use mui for the components and recharts for the graphs.

The installation is quite straightforward, and maybe the easiest in the whole tutorial:

# installation for mui
> yarn add @mui/material @emotion/react @emotion/styled

# installation for recharts
> yarn add recharts

We want to allow the user to visually see the stock amount and rating for the visible category, so they can understand how is the proportion balanced. This is the perfect scenario for a RadialBarChart in recharts. Then, to toggle which value to visualize (either stocks or rating) we can use ToggleButton from mui. This is a possible implementation of it:

// src/components/CategoryChart.tsx
import { useStore } from '@nanostores/react';
import React, { useMemo } from 'react';
import {
  RadialBarChart,
  RadialBar,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import type { Product } from '../types';
import { hiddenCategories } from '../state';
import randomColor from 'randomcolor';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';

const style = {
  top: '50%',
  right: 0,
  transform: 'translate(0, -50%)',
  lineHeight: '30px',
};

type Props = {
  products: Product[];
};

type View = 'stock' | 'rating';

const groupByCategory = (products: Product[]) => {
  return products.reduce((acc, curr) => {
    const existingCategory = acc[curr.category];

    acc[curr.category] = existingCategory
      ? existingCategory.concat(curr)
      : [curr];
    return acc;
  }, {} as Record<string, Product[]>);
};

const CategoryChart = ({ products }: Props) => {
  const [view, setView] = React.useState<View>('stock');
  const disabledCategories = useStore(hiddenCategories);

  const data = useMemo(() => {
    const visibleCategories = products
      .map((product) => product.category)
      .filter((product) => !disabledCategories.includes(product))
      .filter((val, i, arr) => arr.indexOf(val) === i);

    const groupedByCategory = groupByCategory(products);

    return visibleCategories.map((category) => {
      const productsInCategory = groupedByCategory[category];
      const avg =
        productsInCategory.reduce((acc, curr) => acc + curr.rating, 0) /
        products.length;

      return {
        name: category,
        stock: productsInCategory.reduce((acc, curr) => acc + curr.stock, 0),
        rating: Math.round(avg * 100) / 100,
        fill: randomColor({ seed: category, luminosity: 'dark' }),
      };
    });
  }, [products, disabledCategories]);

  return (
    <div style={{ width: '100%', height: 400, position: 'relative' }}>
      <ResponsiveContainer width="100%" height="100%">
        <RadialBarChart
          cx="50%"
          cy="50%"
          innerRadius="20%"
          outerRadius="80%"
          barSize={15}
          data={data}
        >
          <RadialBar
            minAngle={15}
            label={{ position: 'insideStart', fill: '#fff' }}
            background
            dataKey={view}
          />

          <Legend
            iconSize={20}
            layout="vertical"
            verticalAlign="middle"
            wrapperStyle={style}
          />
        </RadialBarChart>
      </ResponsiveContainer>

      <ToggleButtonGroup
        style={{ position: 'absolute', top: 0 }}
        color="primary"
        value={view}
        exclusive
        onChange={(_, val) => val && setView(val)}
      >
        <ToggleButton value="stock">Stock</ToggleButton>
        <ToggleButton value="rating">Rating</ToggleButton>
      </ToggleButtonGroup>
    </div>
  );
};

export default CategoryChart;

Let’s add it to our page and check how it integrates into our dashboard!

// src/pages/index.astro
---
import Layout from '../layouts/Layout.astro';
import type { Product } from '../types';

import ProductTable from '../components/ProductTable.svelte';
import CategoryTags from '../components/CategoryTags.svelte';
import Overview from '../components/Overview.vue';
import CategoryChart from '../components/CategoryChart';

// fetch data
const products: Product[] = await fetch('https://dummyjson.com/products')
  .then((res) => res.json())
  .then((res) => res.products);
---

<Layout title="Welcome to Astro.">
  <CategoryTags products={products} client:load />
+  <Overview products={products} client:load />
  <CategoryChart products={products} client:load />
  <ProductTable products={products} client:load />
</Layout>

Stock and rating are now visible for each category

Making the app responsive

Now that we have all our components fully coded and integrated, we need to take care of the applicationโ€™s responsiveness. This is where Tailwind can make our life easier, and in a couple of lines, we have fantastic results!

//src/pages/index.astro
---
import Layout from '../layouts/Layout.astro';
import type { Product } from '../types';

import ProductTable from '../components/ProductTable.svelte';
import CategoryTags from '../components/CategoryTags.svelte';
import Overview from '../components/Overview.vue';
import CategoryChart from '../components/CategoryChart';

// fetch data
const products: Product[] = await fetch('https://dummyjson.com/products')
  .then((res) => res.json())
  .then((res) => res.products);
---

<Layout title="Welcome to Astro.">
+  <div class="tw-grid tw-gap-4 tw-grid-cols-3 lg:tw-grid-cols-5 md:tw-gap-10">
+    <div class="tw-col-span-1">
      <CategoryTags client:load products={products} />
+    </div>

+    <div class="tw-col-span-1">
      <Overview client:load products={products} />
+    </div>

+    <div class="tw-col-span-3">
      <CategoryChart client:only="react" products={products} />
+    </div>

+    <div class="tw-col-span-full">
      <ProductTable client:load products={products} />
+    </div>
  </div>
</Layout>

Now that we have all our components fully coded and integrated, we need to take care of the applicationโ€™s responsiveness. This is where Tailwind can make our life easier, and in a couple of lines, we have fantastic results!

The mobile view of our app
The mobile view of our app

Letโ€™s go for a mobile-first approach. The Categories tag and overview are rendered in one row; below it, we have the chart, and at the bottom (with some scrolling required) is the Product table.

This image illustrates the tablet view of our website, offering a balance between the desktop and mobile versions for a convenient browsing experience on a larger screen.

The tablet view of our app
The tablet view of our app

Finally, we have the desktop view of our website, with a wide layout and padding added on the sides to center the content horizontally.

The desktop view of our app
The desktop view of our app

Conclusion

In a previous article, we discussed the architecture behind Astro which makes it possible to build this kind of application. I highly recommend checking out that article in case you are not familiar with Astroโ€™s implementation of islands architecture.

Astro is a useful tool that can help us build complex and versatile software by allowing us to combine multiple frameworks. As our software systems and requirements evolve, Astro can assist us in easily and efficiently migrating our applications to new frameworks, saving us time, effort, and resources.

On top of that, it also helps us optimize our applications by ensuring that we are not using more JavaScript than necessary. This helps keep our application lightweight and performant, which can improve user experience and the overall functionality of the application. This feature of Astro makes it valuable when building efficient and high-performing applications that meet the needs of our users.

Thanks for reading, and let’s keep building stuff together! ๐Ÿ‘ทโ€โ™‚๏ธ

References

 

Are you adding new JS libraries to improve performance or build new features? What if theyโ€™re doing the opposite?

Thereโ€™s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, youโ€™ll need more visibility to ensure your users donโ€™t run into unknown issues.

LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.

https://logrocket.com/signup/

LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your appโ€™s performance, reporting metrics like client CPU load, client memory usage, and more.

Build confidently โ€” .

Emanuel Suriano Hi ๐Ÿ‘‹ I build stuff with JavaScript ๐Ÿ’ป Once a month I write an article โœ๏ธ and sometimes I give talks ๐Ÿ’ฌ

2 Replies to “Building a multi-framework dashboard with Astro”

  1. Hi and thanks for the tutorial.
    It’s interesting the idea to combine components created using different frameworks.

    I’m following the steps but using npm instead of yarn, and I’m facing an error while trying to install the svelte-material-design-ui. The astro CLI is outputing that “svelte-material-ui is not an official Astro package” and if I chose to continue anyway it fails and returns “svelte-material-ui doesn’t appear to be an integration or an adapter”.

    Any solution?

    Thanks ๐Ÿ™‚

  2. Hey Bernat,

    Have you tried forking the repo, and install the dependencies using npm? The Astro ecosystem is constantly changing and most probably you ended with a more up-to-date dependency of the tool. Try installing the same version that I used, and see if the warning is gone.

    Also, I highly recommend joining the Astro Discord channel. I encountered many issues while writing the article, and I found myself talking in one of the help channels about issues rather than scrolling through Stack Overflow ๐Ÿ™‚

    Hope you can find the solution!

Leave a Reply