Mettre à jour son app Nuxt en temps réel avec Supabase Realtime.

Also available in English : Real-time Nuxt App updates made easy with Supabase
Découvrez comment mettre à jour l'UI de votre app Nuxt 3 en temps réel lors d'un update en base de données grâce à Supabase Realtime.

En 2020, j'ai créé mon premier side project : Compar'or avec Nuxt 2, Express pour l'API et MongoDB Atlas pour la DB.

Le but du projet était de créer un site permettant de comparer le prix des pièces d'or des différents sites de vente français.

Grâce à un cronjob, un script de webscraping (Puppeteer 👀) se lançait toutes les 5 minutes pour récupérer le prix des pièces d'or et mettre à jour la database.

Objectif

Dans cet article, je vais partager avec vous comment j'ai amélioré l'expérience utilisateur de mon app grâce à Supabase Realtime.

Problème : l'utilisateur doit recharger la page toutes les 5 minutes pour récupérer les données de prix à jour.

Solution : Créer un composant qui permet d'écouter les changements de notre DB PostgreSQL grâce à Supabase Realtime (via WebSockets). Chaque fois que le prix est mis à jour, nous affichons une animation en temps réel qui montre que le prix a augmenté ou diminué .

Bénéfices :

  • Capter et diriger l'attention de l'utilisateur
  • Souligner les informations importantes
  • Suggérer un flux naturel
  • Contribue à créer une image de marque plus professionnelle
Rendu final Supabase Realtime

Prérequis

Pour suivre cet article, vous devrez installer :

Création de notre projet Supabase

Avant de commencer à développer la feature, nous allons créer la base de données et notre API grâce à Supabase ✨

  1. Aller sur app.supabase.com.
  2. Inscrivez-vous avec votre compte GitHub. Ensuite, cliquez sur New Project et suivez les instructions :
Création de projet sur Supabase
  1. Ensuite cliquez sur Table editor puis créez une table en cochant les cases Enable RLS et Enable Realtime comme suit :
Création de notre table "coins"
  1. Insèrez les données suivantes dans la table :
Ajout de 2 pièces dans notre table

Comprendre l'option "Row-Level-Security" de PostgreSQL

Avec Supabase, nous pouvons accéder à nos données directement depuis le client (le navigateur). Le client a besoin d'un accès à la DB pour pouvoir interagir avec nos données et c'est pour cela que nous lui passerons, dans l'étape suivante, notre Supabase URL et notre Anon key.

Cela soulève une question intéressante :

Si ma clé anon est dans le client, quelqu'un ne peut-il pas lire mon Javascript et voler ma clé ?

La réponse est oui. Il est donc indispensable d'activer l'option RLS lorsqu'on crée une table. D'ailleurs, Supabase nous indique bien "Restrict access to your table by enabling RLS and writing Postgres policies. If RLS is not enabled, anyone with the anon key can modify and delete your data."

En utilisant les Row-Level-Security policies de PostgreSQL, nous pouvons définir des règles sur les données auxquelles la clé anon est autorisée à accéder. Si le RLS est activé, mais qu'il n'y a aucune règle définie, la clé anon ne donne pas accès aux données.

Dans notre projet, nous allons setup une nouvelle règle qui autorise la clé anon à accéder aux données (read only) de la table coins, comme suit :

Sélectionnez la règle 'Enable read access to everyone'

Création de notre projet Nuxt 3

Ouvrez un terminal (si vous utilisez Visual Studio Code, vous pouvez ouvrir un terminal intégré) et utilisez la commande suivante pour créer un nouveau projet Nuxt 3 :


npx nuxi init <project-name>

Après avoir setup votre app, installez le package sass grâce à cette commande :


yarn add -D sass

1. Add @nuxtjs/supabase

Nous utiliserons le module @nuxtjs/supabase crée par la communauté Nuxt pour avoir la meilleure Developer Experience possible. Rien de plus simple comme installation, let's go to : Getting started with @nuxtjs/supabase

Une fois le module installé, direction la homepage de votre app sur Supabase pour récupérer votre Supabase URL et votre Anon key :

Les clés qui permettront de lier le client et notre DB/API

Ensuite, créer un fichier .env à la racine de votre projet et y ajouter les clés récupérées sur Supabase :

.env
Copy

SUPABASE_URL="https://example.supabase.co"
SUPABASE_KEY="<your_key>"

2. Add @nuxtjs/tailwindcss

Ce framework CSS n'est plus à présenter, il nous permettra d'ajouter rapidement du style à notre app. Pour l'installer, suivez les instructions de la doc officielle : Getting started with @nuxtjs/tailwindcss.

3. Add @heroicons/vue

Nous allons également utiliser Heroicons. Pour installer ce package d'icônes :


yarn add @heroicons/vue

Ensuite ajoutez ces lignes de configuration dans votre fichier nuxt.config.ts :

nuxt.config.ts
Copy

import { defineNuxtConfig } from "nuxt";
export default defineNuxtConfig({
modules: ["@nuxtjs/supabase", "@nuxtjs/tailwindcss"],
build: {
transpile: ["@heroicons/vue"],
},
});

Si ce n'est pas déjà fait, lancez votre app avec la commande yarn dev, npm run dev ou pnpm run dev (selon le package manager node que vous avez choisi lors de l'installation de Nuxt) et vérifiez que tout fonctionne bien.

Création de la structure de notre app

  1. Créer les dossiers public, components et pages à la racine de votre projet.
  2. Créer un fichier index.vue dans le dossier pages.
  3. Créer les fichiers CoinCard.vue, CoinCardUpIcon.vue et CoinCardDownIcon.vue dans le dossier components.
  4. Créer un fichier types.ts à la racine du projet et y ajouter le type Coin :
types.ts
Copy

export type Coin = {
id: string;
name: string;
weight: number;
img: string;
price: number;
change?: string;
color?: string;
diff?: number;
}

  1. Importer 2 images de pièces dans le dossier public. Vous pouvez utiliser celles-ci :
Nuxt - Supabase - CoinNuxt - Supabase - Coin

Attention, les images doivent avoir le même nom que les valeurs de la colonne img de votre table coins sur Supabase. En effet, nous avons utilisé le path /20_marianne.png et /20_vreneli.png. Les images doivent donc se nommer 20_marianne.png et 20_vreneli.png et être dans le dossier public de notre projet.

La structure de votre projet devrait ressembler à cela :

App Nuxt Structure - Supabase

Création de la page d'accueil

Dans notre fichier app.vue, nous allons remplacer le composant <NuxtWelcome /> par le composant <NuxtPage /> :

app.vue
Copy

<template>
<div>
<NuxtPage />
</div>
</template>

Le composant <NuxtPage /> est nécessaire pour afficher les pages de premier niveau ou imbriquées situées dans le répertoire /pages. Ainsi, la homepage de notre app sera /pages/index.vue.

Dans la balise <script> de notre fichier index.vue, nous allons :

  1. Importer les déclarations de types pour faciliter notre développement avec TypeScript.
  2. Initialiser notre client Supabase afin d'interagir avec notre DB.
  3. Récupérer toutes les pièces que contient notre table "coins" et affecter le résultat de la requête à notre tableau coins.
  4. Une fois que le composant est monté, on s'abonne à la table "coins" de notre DB pour être notifié en temps réel des changements lors d'un "UPDATE".
  5. payload est un objet qui contient les données de la pièce updated. Nous trouverons l'index de la pièce à update dans notre tableau coins en comparant l'id de la pièce updated avec l'id de chaque pièce de notre tableau à l'aide de la méthode findIndex.
  6. Création d'une condition qui check si l'ancien prix est différent du nouveau. Si oui, alors on appelle la fonction onChangePrice.
  7. Cette fonction prend en paramètre l'index de la pièce à update et l'objet newCoin.
  8. On compare l'ancien prix et le nouveau prix de la pièce pour déterminer quel symbole, et quelle classe CSS attribuer à la pièce qui vient d'être updated en plus de modifier son prix.
  9. Après 3s, on réinitialise les propriétés change et color de la pièce pour qu'elle revienne à son rendu initial.
  10. On oublie pas de désabonner le client Supabase lorsque le composant est unMounted (quand l'utilisateur quitte la page) afin d'éviter les fuites de mémoire 😉.
index.vue
Copy

_51
<script setup lang="ts">
_51
import type { RealtimeSubscription } from "@supabase/supabase-js";
_51
import type { Coin } from "@/types";
_51
_51
const supabase = useSupabaseClient();
_51
let subscription: RealtimeSubscription;
_51
_51
const coins = ref<Coin[]>([]);
_51
const { data } = await supabase.from<Coin>("coins").select("*");
_51
coins.value = data;
_51
_51
onMounted(() => {
_51
subscription = supabase
_51
.from("coins")
_51
.on("UPDATE", (payload) => {
_51
const newCoin = payload.new;
_51
const currentCoinIndex = coins.value.findIndex((coin) =>
_51
coin.id === newCoin.id
_51
);
_51
_51
if (coins.value[currentCoinIndex].price !== newCoin.price) {
_51
onChangePrice(currentCoinIndex, newCoin);
_51
}
_51
})
_51
_51
.subscribe();
_51
});
_51
_51
onUnmounted(() => {
_51
supabase.removeSubscription(subscription);
_51
});
_51
_51
const onChangePrice = (currentCoinIndex: number, newCoin: Coin) => {
_51
const symbol =
_51
coins.value[currentCoinIndex].price > newCoin.price ? "-" : "+";
_51
_51
coins.value[currentCoinIndex] = {
_51
...coins.value[currentCoinIndex],
_51
price: newCoin.price,
_51
change: symbol,
_51
color: symbol === "+" ? "price-up-color" : "price-down-color",
_51
diff: parseFloat(
_51
Math.abs(newCoin.price - coins.value[currentCoinIndex].price).toFixed(2)
_51
),
_51
};
_51
setTimeout(() => {
_51
coins.value[currentCoinIndex].change = null;
_51
coins.value[currentCoinIndex].color = null;
_51
}, 3000);
_51
};
_51
</script>

La majeure partie de notre logique est écrite. Il ne nous reste plus qu'à créer notre composant <CoinCard /> qui sera réutilisé pour chaque pièce de notre tableau coins. Sans oublier nos composants <CoinCardUpIcon /> et <CoinCardDownIcon /> qui seront importés dynamiquement dans notre composant <CoinCard />.

Créons d'abord les composants <CoinCardUpIcon /> et <CoinCardDownIcon /> :

CoinCardUpIcon.vue
CoinCardDownIcon.vue
Copy

<script setup lang="ts">
import { ChevronDoubleUpIcon } from "@heroicons/vue/24/solid";
</script>
<template>
<span
class="absolute right-0 top-0 h-10 w-10 -mr-3 -mt-3 rounded-full bg-red-500 flex items-center justify-center"
>
<ChevronDoubleUpIcon class="absolute h-6 w-6 text-white animate-pulse" />
</span>
</template>

Maintenant, créons notre composant <CoinCard />. Ce composant récupérera la pièce de notre tableau coins qu'on lui passera en props dans notre composant parent index.vue.

CoinCard.vue
Copy

<script setup lang="ts">
import CoinCardDownIcon from "@/components/CoinCardDownIcon.vue";
import CoinCardUpIcon from "@/components/CoinCardUpIcon.vue";
import type { Coin } from "@/types";
const props = defineProps({
coin: {
type: Object as () => Coin,
required: true,
},
});
const icon = computed(() => {
if (props.coin?.change) {
return props.coin.change === "+" ? CoinCardUpIcon : CoinCardDownIcon;
}
return null;
});
const colorClass = computed(() => {
if (props.coin?.color) {
return props.coin.color;
}
return null;
});
</script>
<template>
<div class="relative">
<Transition name="fade">
<component class="shadow-xl z-10" :is="icon" />
</Transition>
<div class="card-glassmorphism group cursor-pointer">
<div
class="aspect-w-3 aspect-h-2 sm:aspect-none bg-slate-900 bg-opacity-25"
>
<img
:src="props.coin.img"
class="py-6 mx-auto object-contain max-w-[200px]"
/>
</div>
<div
class="flex-1 flex flex-col text-center relative"
>
<h3 class="text-lg xs:text-xl py-4 px-5 font-medium text-slate-200">
<div>
{{ props.coin.name }}
</div>
<span
class="inline-flex text-xs font-bold group-hover:text-amber-400 transition duration-150 ease-in-out"
>poids : {{ props.coin.weight }}gr</span
>
</h3>
<div class="flex items-center justify-center w-full text-base xs:text-sm sm:text-lg">
<div
class="inline-flex text-white shadow-md hover:shadow-lg focus:shadow-lg items-center w-full"
:class="colorClass ? colorClass : 'bg-amber-400'"
role="group"
>
<button
type="button"
class="btn-price-left w-full"
:class="colorClass"
>
<span v-if="!props.coin.change">
Meilleur prix
</span>
<span v-else class="animate-pulse">
{{ props.coin.change }} {{ props.coin.diff }} €
</span>
</button>
<button
type="button"
class="btn-price-right w-full"
:class="colorClass"
>
{{ props.coin.price }} €
</button>
</div>
</div>
</div>
</div>
</div>
</template>

Le style de notre composant <CoinCard /> (à mettre dans la balise <style> de notre composant) :

CoinCard.vue
Copy

<style lang="scss">
.fade-enter-active {
transition: all 0.3s ease-in-out;
}
.fade-leave-active {
transition: all 0.5s cubic-bezier(1, 0.5, 0.8, 1);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.btn-price-left {
border-bottom-left-radius: 16px;
@apply px-4 py-5 shadow-lg z-10 bg-opacity-80 font-bold leading-tight focus:outline-none focus:ring-0 transition ease-in-out;
}
.btn-price-right {
border-bottom-right-radius: 16px;
@apply px-4 py-5 bg-opacity-80 font-medium leading-tight uppercase group-hover:bg-yellow-500 focus:bg-yellow-400 focus:outline-none focus:ring-0 active:bg-yellow-700 transition ease-in-out;
}
.price-up-color {
@apply bg-red-500 group-hover:bg-red-500 focus:bg-red-400 focus:outline-none focus:ring-0 active:bg-red-700;
transition: all 0.5s ease-in-out;
}
.price-down-color {
@apply bg-green-500 group-hover:bg-green-500 focus:bg-green-400 focus:outline-none focus:ring-0 active:bg-green-700;
transition: all 0.5s ease-in-out;
}
.card-glassmorphism {
z-index: 1;
background-image: radial-gradient(
circle farthest-corner at 10% 20%,
rgba(90, 92, 106, 1) 0%,
rgba(32, 45, 58, 1) 81.3%
);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.293);
backdrop-filter: blur(4.7px);
-webkit-backdrop-filter: blur(4.7px);
border: 1px solid rgba(199, 197, 188, 0.457);
transition: all 0.3s, box-shadow 1s;
@apply flex flex-col overflow-hidden;
&::before {
content: "";
position: absolute;
z-index: -1;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-image: radial-gradient(
circle farthest-corner at -4% -12.9%,
rgba(74, 98, 110, 1) 0.3%,
rgba(30, 33, 48, 1) 90.2%
);
transition: all 0.2s linear;
opacity: 0;
}
&:hover {
box-shadow: 0 0 10px rgba(255, 255, 255, 0.147);
transform: scale(1.009) perspective(0px);
transition: all 0.5s ease-in-out;
}
&:hover:before {
opacity: 1;
}
}
</style>

Enfin, nous pouvons retourner sur notre page index.vue et importer notre composant <CoinCard /> dans la balise <template> :

index.vue
Copy

<template>
<main
className="bg-gray-800 min-h-screen flex flex-col items-center justify-center"
>
<div class="max-w-2xl mx-auto flex flex-wrap justify-center gap-8">
<CoinCard
v-for="coin in coins"
:key="coin.id"
:coin="coin"
class="min-w-[300px]"
/>
</div>
</main>
</template>

Résultat

View + Supabase

Libre à vous d'incorporer cette feature dans votre prochain side-project. Que ce soit lors d'un update de prix, d'un ajout de produit ou d'une suppression, vous pouvez être sûr que vos utilisateurs apprécieront cette fonctionnalité 🚀

Live example : https://nuxt3-demo-supabase-realtime.vercel.app
Code source : Github