Real-time product-auction NuxtJS app & Supabase

Eugene Musebe

Public competitive sales have always been a key component of our society from expensive jewelry to salvage cars. Having auctions online allows us to reduce costs while increasing the geographic limits of our attendees. In this article, we explore how we can build a real-time app to enable live product auctions.

Codesandbox

The completed project is available on Codesandbox.

You can find the full codebase on my Github

Project initialization

We will be using NuxtJS as our framework of choice. This is because of how simple and performant it is as a VueJS framework.

Before setting up, ensure that you have npx installed. NPX is shipped by default since NPM v5.2/v6.1 or Yarn.

Open the terminal and run the following command in your preferred working directory:

1yarn create nuxt-app nuxtjs-product-auction
2# OR
3npx create-nuxt-app nuxtjs-product-auction
4# OR
5npm init nuxt-app nuxtjs-product-auction

Here are our recommended defaults for the setup questions that will follow:

Projec name: nuxtjs-product-auction Programming language: JavaScript Package manager: Yarn UI Framework: Tailwind CSS Nuxt.js modules: N/A Linting tools: N/A Testing frameworks: None Rendering mode: Universal (SSR/SSG) Deployment target: Server (Node.js hosting) Development tools: N/A

Once the project is set up, enter the project and run it:

1cd nuxtjs-product-auction
2
3yarn dev
4# OR
5npm run dev

Supabase Setup

Supabase prides itself as the open-source firebase alternative which allows us to create a backend in less than 2 minutes. We will use it to store the bids we receive. If you do not have an account, feel free to create one here. Create a project called nuxtjs-product-auction and a table with the following structure.

We want to ensure that our table supports real-time events. To do this, proceed to Database > Replication under the supabase_realtime row navigate to Source on the right, and ensure that the bids table is enabled.

We are now going to install the supabase-js, the supported JavaScript library.

1npm install @supabase/supabase-js
2# OR
3yarn add @supabase/supabase-js

We now need to set the required environment variables. Create the .env file in the project top folder.

1touch .env

This will store the sensitive credentials we do not want to be exposed in our codebase.

On supabase, navigate to settings > API to obtain your URL and public anon key. We are going to add these to our .env file.

1<!-- .env -->
2NUXT_ENV_SUPABASE_URL=https://<your-url>.supabase.co
3NUXT_ENV_SUPABASE_KEY=<your-public-anon-key>

Cloudinary account setup

We will store our images and videos on Cloudinary. This will enable us to use their image gallery component. To create an account, you may signup here. Once registered, you will see your cloud name on your dashboard. Add it to your .env file.

1<!-- .env -->
2NUXT_ENV_CLOUDINARY_CLOUD_NAME=<cloudinary-cloud-name>
3NUXT_ENV_SUPABASE_URL=https://<your-url>.supabase.co
4NUXT_ENV_SUPABASE_KEY=<your-public-anon-key>

Proceed to your media library on your Cloudinary dashboard and create a folder called nuxtjs-product-action and a subfolder called apples. Upload the following files to the apples subfolder.

You should now have a folder similar to this one:

Once this is done, select all the files in the folder cmd/ctr + A and tag them npa-apples. We will use this tag to display the images and videos in our gallery.

Cloudinary gallery setup

We first need to load the source file for the product gallery. This is a single JavaScript file we add to our nuxt.config.js > scripts section.

1// nuxt.config.js
2export default {
3 head: {
4 ...
5 script: [
6 { src: 'https://product-gallery.cloudinary.com/all.js' },
7 ],
8 },
9 ...
10}

Let us add the basic HTML to contain our product gallery.

1<!-- pages/index.vue -->
2<template>
3 ....
4 <div id="my-gallery"></div>
5 ...

We can now configure and render our widget once our page is mounted.

1// pages/index.vue
2<script>
3export default {
4 data(){
5 return {
6 gallery:null,
7 ...
8 }
9 },
10
11 mounted(){
12 this.gallery = cloudinary.galleryWidget({
13 container: "#my-gallery",
14 cloudName: process.env.NUXT_ENV_CLOUDINARY_CLOUD_NAME,
15 aspectRatio: "16:9",
16 mediaAssets: [
17 { tag: "npa-apple", },
18 { tag: "npa-apple", mediaType: "video" },
19 ],
20 videoProps: { playerType: "cloudinary" }
21 });
22 this.gallery.render();
23 },
24 ...
25}
26</script>

In the above snippet, we ensure the widget uses our account by specifying the cloud name. We set the aspect ratio so that our images and videos are viewed consistently. In the media assets, we tell our widget to get both images and videos tagged npa-apple. We also specify to use the Cloudinary video player instead of the normal HTML5 player before we render the gallery.

Displaying current bids

To get the current bids from our Supabase database, we need to initialize the client and run the query when our page is mounted.

1// pages/index.vue
2<script>
3import { createClient } from '@supabase/supabase-js'
4export default {
5 data(){
6 return {
7 ...
8 supabase:null,
9 bids:null,
10 ...
11 }
12 },
13 mounted(){
14 ...
15 this.supabase = createClient(
16 process.env.NUXT_ENV_SUPABASE_URL,
17 process.env.NUXT_ENV_SUPABASE_KEY
18 );
19
20 this.loadBids();
21 ...
22 },
23 methods:{
24 async loadBids(){
25 const resp = await this.supabase
26 .from('bids')
27 .select()
28 .order('id', { ascending: false });
29
30 if(resp.status != 200){
31 console.log(resp);
32 }
33
34 this.bids = resp.data;
35 },
36 ...
37 }
38}
39</script>

We can now display the bids in our HTML.

1<!-- pages/index.vue -->
2<template>
3 ...
4 <p v-if="!bids">Loading bids...</p>
5 <ul v-else role="list" >
6 <li v-for="bid in bids" :key="bid.id">
7 <div >
8 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
10 </svg>
11 <div>
12 <div>
13 <h3>{{bid.name}}</h3>
14 <p>$ {{new Intl.NumberFormat().format(bid.amount)}}</p>
15 </div>
16 <p>
17 {{
18 (new Date(bid.created_at)).toLocaleString("en-US")
19 }}
20 </p>
21 </div>
22 </div>
23 </li>
24 </ul>
25 ...
26</template>

We format the amount and the created_at to make them more visually appealing.

Displaying the winning bid

Displaying the winning bid is similar to displaying all the bids. We order the bids with the amount column descending and fetch the first row only.

1// pages/index.vue
2<script>
3export default {
4 data(){
5 return {
6 ...
7 winningBid:null,
8 ...
9 }
10 },
11
12 ...
13 methods:{
14 async loadBids(){
15 this.loadWinningBid();
16 ...
17 },
18 async loadWinningBid(){
19 const resp = await this.supabase
20 .from('bids')
21 .select()
22 .order('amount', { ascending: false })
23 .limit(1)
24 .single();
25
26 if(resp.status != 200){
27 console.log(resp);
28 }
29
30 this.winningBid = resp.data;
31 },
32
33}
34</script>

We will be loading with the winning bid each time loadBids is called. Let us now display the winning bid.

1// pages/index.vue
2<template>
3 ...
4 <ul v-if="winningBid" role="list">
5 <li>
6 <div>
7 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
8 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
9 </svg>
10 <div>
11 <div>
12 <h3>{{winningBid.name}}</h3>
13 <p>$ {{new Intl.NumberFormat().format(winningBid.amount)}}</p>
14 </div>
15 <p>
16 {{
17 (new Date(winningBid.created_at)).toLocaleString("en-US")
18 }}
19 </p>
20 </div>
21 </div>
22 </li>
23 </ul>
24 ...
25</template>

Adding a new bid

To add a new bid, we first add an HTML form to capture the name and amount of the bid.

1<template>
2 ...
3 <form @submit.prevent="sendBid">
4 <div>
5 <label for="name"> Names </label>
6 <div class="mt-1">
7 <input
8 id="name"
9 name="name"
10 type="text"
11 v-model="newBid.name"
12 required
13 />
14 </div>
15 </div>
16
17 <div>
18 <label for="amount"> Amount </label>
19 <div>
20 <input
21 id="amount"
22 name="amount"
23 type="number"
24 v-model="newBid.amount"
25 required
26 />
27 </div>
28 </div>
29
30 <div>
31 <button type="submit">Place Bid</button>
32 </div>
33 </form>
34 ...
35 </template>

Once the form is submitted, we can not add the new entry into the bids table.

1// pages/index.vue
2<script>
3export default {
4 data(){
5 return {
6 ...
7 newBid:{
8 name:null,
9 amount:null
10 }
11 }
12 },
13 ...
14 methods:{
15 ...
16 async sendBid(){
17 const resp = await this.supabase
18 .from('bids')
19 .insert([
20 this.newBid
21 ]);
22
23 if(resp.status != 201){
24 console.log(resp);
25 }
26
27 this.newBid = {
28 name:null,
29 amount:null
30 };
31 },
32 }
33}
34</script>

Once the bid is submitted, we reset the newBid object which resets the form inputs.

Listening for new bids

Once our bid has been submitted or any user has submitted their bids, we need to be notified so that we can reload the bids again. We will use Supabase subscriptions for this.

We need to listen to any events on our bids table. If there are any events we reload the bids. Before we close our page we also want to close our subscriptions so that our server doesn't send subscriptions to inactive clients.

1// pages/index.vue
2<script>
3export default {
4 ...
5 mounted(){
6 ...
7 this.listenToBids();
8 },
9 beforeDestroy(){
10 this.supabase.removeAllSubscriptions();
11 },
12 methods:{
13 ...
14 listenToBids(){
15 const subscription = this.supabase
16 .from('bids')
17 .on('*', () => {
18 this.loadBids();
19 })
20 .subscribe()
21 },
22 ...
23 }
24}
25</script>

Closing

With the above code, we now have a live product action app. Feel free to add additional features such as authentication, multiple product auctions as well as start and end time limits.

To learn more about the platforms we have used feel free to review the Cloudinary documentation or the Supabase reference.

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.