DEV Community

Cover image for Building a bidding system with NextJS πŸš€
Nevo David for novu

Posted on • Originally published at novu.co

Building a bidding system with NextJS πŸš€

TL;DR

NextJS introduced its new server actions components, and I had to test them out to see what is all the fuss about πŸ₯³

I have built a simple app where you can register to the system, add a new product, and bid on it.

Once the bid is in, it will notify the other bidders that they have been outbid.

It will also inform the seller of a new bid in the system.


Novu - the open-source notification infrastructure

Just a quick background about us. Novu is the first open-source notification infrastructure. We basically help to manage all the product notifications. It can be In-App (the bell icon like you have in the Dev Community - Websockets), Emails, SMSs, etc.

I would be super happy if you could give us a star! It will help me to make more articles every week πŸš€

https://github.com/novuhq/novu

Novu


Installing the project

We will start the project by initiating a new NextJS project:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

And mark the following details

βœ” What is your project named? … new-proj
βœ” Would you like to use TypeScript with this project? … No / Yes
βœ” Would you like to use ESLint with this project? … No / Yes
βœ” Would you like to use Tailwind CSS with this project? … No / Yes
βœ” Would you like to use `src/` directory with this project? … No / Yes
βœ” Use App Router (recommended)? … No / Yes
βœ” Would you like to customize the default import alias? … No / Yes
Enter fullscreen mode Exit fullscreen mode

Let's go into the folder

cd new-proj
Enter fullscreen mode Exit fullscreen mode

And modify our next.config.js to look like this

/** @type {import('next').NextConfig} */
const nextConfig = {
    experimental: {
        serverActions: true
    }
}

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

We are adding the ability to use the server actions as it's currently still in the beta stage. This will allow us to call the server directly from the client πŸ’₯


Creating a database

For our project, we are going to use Postgres. Feel free to host it yourself (use docker), neon.tech, supabase, or equivalent. For our example, we will use Vercel Postgres.

You can start by going to Vercel Storage area and creating a new database.

We will start by adding our bid table. It will contain the product's name, the product, the owner of the product, and the current amount of bids.

Click on the query tab and run the following query

create table bids
(
    id         SERIAL PRIMARY KEY,
    name       varchar(255),
    owner      varchar(255),
    total_bids int default 0 not null
);
Enter fullscreen mode Exit fullscreen mode

Click on ".env.local" tab, and copy everything.

Open a new file in our project named .env and paste everything inside.

Then install Vercel Postgres by running.

npm install @vercel/postgres
Enter fullscreen mode Exit fullscreen mode


Building the Login page

We don't want people to have access to any page without logging in (in any path).

For that, let's work on the main layout and put our login logic there.

Edit layout.tsx and replace it with the following code:

import './globals.css'
import {cookies} from "next/headers";
import Login from "@biddingnew/components/login";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const login = cookies().get('login');

  return (
    <html lang="en">
      <body>{login ? children : <Login />}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Very simple react component.

We are getting the login cookie from the user.

If the cookie exists - let Next.JS render any route.

If not, show the login page.

Let's look at a few things here.

  • Our component is "async" which is required when using the new App router directory.
  • We are taking the cookie without any "useState", as this is not a "client only" component, and the state doesn't exist.

If you are unsure about the App router's new capabilities, please watch the video at the bottom as I explain everything.

Let's build the login component

This is a very simple login component, just the person's name, no password or email.

"use client";

import {FC, useCallback, useState} from "react";

const Login: FC<{setLogin: (value: string) => void}> = (props) => {
    const {setLogin} = props;
    const [username, setUsername] = useState('');
    const submitForm = useCallback(async (e) => {
        setLogin(username);
        e.preventDefault();
        return false;
    }, [username]);

    return (
        <div className="w-full flex justify-center items-center h-screen">
            <form className="bg-white w-[80%] shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={submitForm}>
                <div className="mb-4">
                    <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
                        Username
                    </label>
                    <input
                        onChange={(event) => setUsername(event.target.value)}
                        className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                        id="username"
                        type="text"
                        placeholder="Enter your username"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <button
                        className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
                        type="submit"
                    >
                        Sign In
                    </button>
                </div>
            </form>
        </div>
    )
}

export default Login;
Enter fullscreen mode Exit fullscreen mode

Let's take a few notes here:

  • We are using "use client", which means this component will run over the client. As a result, you can see that we have the "useState" available to us. To clarify, you can use client components inside server components but not vice versa.
  • We have added a requirement for a parameter called setLogin it means once somebody clicks on the submit function, it will trigger the login function.

Let's build the setLogin over the main layout page.

This is where the magic happens πŸͺ„βœ¨

We will create a function using the new Next.JS server-actions method.

The method will be written in the client. However, it will run over the server.

In the background, Next.JS actually sends an HTTP request.

import './globals.css'
import {cookies} from "next/headers";
import Login from "@biddingnew/components/login";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const loginFunction = async (user: string) => {
    "use server";

    cookies().set('login', user);
    return true;
  }

  const login = cookies().get('login');

  return (
    <html lang="en">
      <body className={inter.className}>{login ? children : <Login setLogin={loginFunction} />}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

As you can see, there is a new function called loginFunction, and at the start, there is a "use server" This tells the function to run over the server. It will get the user name from the setLogin and set a new cookie called "login".

Once done, the function will re-render, and we will see the rest of the routes.


Building the bidding page

Let's start by editing our page.tsx file

We will start by adding a simple code for getting all the bids from our database:

const { rows } = await sql`SELECT * FROM bids ORDER BY id DESC`;
Enter fullscreen mode Exit fullscreen mode

Let's also add our login cookie information

const login = cookies().get("login");
Enter fullscreen mode Exit fullscreen mode

The entire content of the page:

import Image from "next/image";
import { sql } from "@vercel/postgres";
import { cookies } from "next/headers";

export default async function Home() {
  const { rows } = await sql`SELECT * FROM bids ORDER BY id DESC`;

  const login = cookies().get("login");

  return (
    <div className="text-black container mx-auto p-4 border-l border-white border-r min-h-[100vh]">
      <div className="flex">
        <h1 className="flex-1 text-3xl font-bold mb-4 text-white">
          Product Listing ({login?.value!})
        </h1>
      </div>
      <div className="grid grid-cols-3 gap-4">
        {rows.map((product) => (
          <div key={product.id} className="bg-white border border-gray-300 p-4">
            <div className="text-lg mb-2">
              <strong>Product Name</strong>: {product.name}
            </div>
            <div className="text-lg mb-2">
              <strong>Owner</strong>: {product.owner}
            </div>
            <div className="text-lg">
              <strong>Current Bid</strong>: {product.total_bids}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Very simple component.

We get all the bids from the database, iterate and display them.

Now let's create a simple component to add new products.

Create a new component named new.product.tsx

"use client";

import { FC, useCallback, useState } from "react";

export const NewProduct: FC<{ addProduct: (name: string) => void }> = (
  props
) => {
  const { addProduct } = props;
  const [name, setName] = useState("");
  const addProductFunc = useCallback(() => {
    setName("");
    addProduct(name);
  }, [name]);
  return (
    <div className="flex mb-5">
      <input
        value={name}
        placeholder="Product Name"
        name="name"
        className="w-[23.5%]"
        onChange={(e) => setName(e.target.value)}
      />
      <button
        type="button"
        onClick={addProductFunc}
        className="w-[9%] bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
      >
        New Product
      </button>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

The component looks almost exactly like our login component.

Not the moment the user adds a new product, we will trigger a function that will do that for us.

  const addProduct = async (product: string) => {
    "use server";

    const login = cookies().get("login");
    const { rows } =
      await sql`INSERT INTO bids (name, owner, total_bids) VALUES(${product}, ${login?.value!}, 0) RETURNING id`;
    revalidatePath("/");
  };
Enter fullscreen mode Exit fullscreen mode
  • We use SQL here directly from the client 🀯 You can also see that the SQL function takes care of any XSS or SQL injections (I haven't used any bindings).
  • We insert into the database a new product. You can see that we use the "owner" by using the name saved in the cookie. We also set the bidding to 0.
  • In the end, we must tell the app to revalidate the path. If not, we will not see the new product.

The final page will look like this:

import { sql } from "@vercel/postgres";
import { cookies } from "next/headers";
import { NewProduct } from "@biddingnew/components/new.product";
import { revalidatePath } from "next/cache";

export default async function Home() {
  const addProduct = async (product: string) => {
    "use server";

    const login = cookies().get("login");
    const { rows } = await sql`INSERT INTO bids (name, owner, total_bids) VALUES(${product}, ${login?.value!}, 0) RETURNING id`;
    revalidatePath("/");
  };

  const { rows } = await sql`SELECT * FROM bids ORDER BY id DESC`;
  const login = cookies().get("login");

  return (
    <div className="text-black container mx-auto p-4 border-l border-white border-r min-h-[100vh]">
      <div className="flex">
        <h1 className="flex-1 text-3xl font-bold mb-4 text-white">
          Product Listing ({login?.value!})
        </h1>
      </div>
      <NewProduct addProduct={addProduct} />
      <div className="grid grid-cols-3 gap-4">
        {rows.map((product) => (
          <div key={product.id} className="bg-white border border-gray-300 p-4">
            <div className="text-lg mb-2">
              <strong>Product Name</strong>: {product.name}
            </div>
            <div className="text-lg mb-2">
              <strong>Owner</strong>: {product.owner}
            </div>
            <div className="text-lg">
              <strong>Current Bid</strong>: {product.total_bids}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Now let's create a new component for adding a bid to a product.

Create a new file called bid.input.tsx and add the following code:

"use client";

import { FC, useCallback, useState } from "react";

export const BidInput: FC<{
  id: number;
  addBid: (id: number, num: number) => void;
}> = (props) => {
  const { id, addBid } = props;
  const [input, setInput] = useState("");

  const updateBid = useCallback(() => {
    addBid(id, +input);
    setInput("");
  }, [input]);

  return (
    <div className="flex pt-3">
      <input
        placeholder="Place bid"
        className="flex-1 border border-black p-3"
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <button type="button" className="bg-black text-white p-2" onClick={updateBid}>
        Add Bid
      </button>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

The component is almost the same as the product component.

The only difference is that it also gets an ID parameter of the current product to tell the server which bid to update.

Now let's add the bidding logic:

  const addBid = async (id: number, bid: number) => {
    "use server";

    const login = cookies().get("login");
    await sql`UPDATE bids SET total_bids = total_bids + ${bid} WHERE id = ${id}`;

    revalidatePath("/");
  };
Enter fullscreen mode Exit fullscreen mode

A very simple function that gets the bid id and increases the total.

The full-page code should look like this:

import { sql } from "@vercel/postgres";
import { cookies } from "next/headers";
import { NewProduct } from "@biddingnew/components/new.product";
import { revalidatePath } from "next/cache";
import { BidInput } from "@biddingnew/components/bid.input";

export default async function Home() {
  const addBid = async (id: number, bid: number) => {
    "use server";

    const login = cookies().get("login");
    await sql`UPDATE bids SET total_bids = total_bids + ${bid} WHERE id = ${id}`;
    revalidatePath("/");
  };

  const addProduct = async (product: string) => {
    "use server";

    const login = cookies().get("login");
    const { rows } =
      await sql`INSERT INTO bids (name, owner, total_bids) VALUES(${product}, ${login?.value!}, 0) RETURNING id`;

    revalidatePath("/");
  };

  const { rows } = await sql`SELECT * FROM bids ORDER BY id DESC`;

  const login = cookies().get("login");

  return (
    <div className="text-black container mx-auto p-4 border-l border-white border-r min-h-[100vh]">
      <div className="flex">
        <h1 className="flex-1 text-3xl font-bold mb-4 text-white">
          Product Listing ({login?.value!})
        </h1>
      </div>
      <NewProduct addProduct={addProduct} />
      <div className="grid grid-cols-3 gap-4">
        {rows.map((product) => (
          <div key={product.id} className="bg-white border border-gray-300 p-4">
            <div className="text-lg mb-2">
              <strong>Product Name</strong>: {product.name}
            </div>
            <div className="text-lg mb-2">
              <strong>Owner</strong>: {product.owner}
            </div>
            <div className="text-lg">
              <strong>Current Bid</strong>: {product.total_bids}
            </div>
            <div>
              <BidInput addBid={addBid} id={product.id} />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We have a fully functional bidding system 🀩

The only thing left is to send notifications to the users where there is a new bid.

Let's do it πŸš€


Adding notifications

We will show a nice bell icon on the right to send notifications between users on a new bid.

Go ahead and register for Novu.

Once done, enter the Settings page, move to the API Keys tab, and copy the Application Identifier.

Let's install Novu in our project

npm install @novu/notification-center
Enter fullscreen mode Exit fullscreen mode

Now let's create a new component called novu.tsx and add the notification center code.

"use client";

import {
    NotificationBell,
    NovuProvider,
    PopoverNotificationCenter,
} from "@novu/notification-center";
import { FC } from "react";

export const NovuComponent: FC<{ user: string }> = (props) => {
    const { user } = props;
    return (
        <>
            <NovuProvider subscriberId={user} applicationIdentifier="APPLICATION_IDENTIFIER">
                <PopoverNotificationCenter onNotificationClick={() => window.location.reload()}>
                    {({ unseenCount }) => <NotificationBell unseenCount={unseenCount!} />}
                </PopoverNotificationCenter>
            </NovuProvider>
        </>
    );
};
Enter fullscreen mode Exit fullscreen mode

The component is pretty simple. Just ensure you update the application identifier with the one you have on the Novu dashboard. You can find the full reference of the notification component over Novu Documentation.

As for the subscriberId, it can be anything that you choose. For our case, we use the name from the cookie, so each time we send a notification, we will send it to the name from the cookie.

This is not a safe method, and you should send an encrypted id in the future. But it's ok for the example :)

Now let's add it to our code.

The full-page code should look something like this:

import Image from "next/image";
import { sql } from "@vercel/postgres";
import { cookies } from "next/headers";
import { NovuComponent } from "@biddingnew/components/novu.component";
import { NewProduct } from "@biddingnew/components/new.product";
import { revalidatePath } from "next/cache";
import { BidInput } from "@biddingnew/components/bid.input";

export default async function Home() {
  const addBid = async (id: number, bid: number) => {
    "use server";

    const login = cookies().get("login");
    await sql`UPDATE bids SET total_bids = total_bids + ${bid} WHERE id = ${id}`;
    const { rows } = await sql`SELECT * FROM bids WHERE id = ${id}`;

    revalidatePath("/");
  };

  const addProduct = async (product: string) => {
    "use server";

    const login = cookies().get("login");
    const { rows } =
      await sql`INSERT INTO bids (name, owner, total_bids) VALUES(${product}, ${login?.value!}, 0) RETURNING id`;

    revalidatePath("/");
  };

  const { rows } = await sql`SELECT * FROM bids ORDER BY id DESC`;

  const login = cookies().get("login");

  return (
    <div className="text-black container mx-auto p-4 border-l border-white border-r min-h-[100vh]">
      <div className="flex">
        <h1 className="flex-1 text-3xl font-bold mb-4 text-white">
          Product Listing ({login?.value!})
        </h1>
        <div>
          <NovuComponent user={login?.value!} />
        </div>
      </div>
      <NewProduct addProduct={addProduct} />
      <div className="grid grid-cols-3 gap-4">
        {rows.map((product) => (
          <div key={product.id} className="bg-white border border-gray-300 p-4">
            <div className="text-lg mb-2">
              <strong>Product Name</strong>: {product.name}
            </div>
            <div className="text-lg mb-2">
              <strong>Owner</strong>: {product.owner}
            </div>
            <div className="text-lg">
              <strong>Current Bid</strong>: {product.total_bids}
            </div>
            <div>
              <BidInput addBid={addBid} id={product.id} />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We can see the Novu notification bell icon, however, we are not sending any notifications yet.

So let's do it!

Every time somebody creates a new product, we will create a new topic for it.

Then on each notification to the same product, we will register subscribers to it.

Let's take an example:

  1. The host creates a new product - a topic is created.
  2. User 1 adds a new bid, registers himself to the topic, and sends everybody registered to the topic that there is a new bid (currently, nobody is registered).
  3. User 2 adds a new bid, registers himself to the topic, and sends everybody registered to the topic that there is a new bid (User 1 gets a notification).
  4. User 3 adds a new bid, registers himself to the topic, and sends everybody registered to the topic that there is a new bid (User 1 gets a notification, and User 2 gets a notification).
  5. User 1 adds a new bid (to the same topic) and sends everybody registered to the topic that there is a new bid (User 2 gets a notification, and User 3 gets a notification).

Now let's go over to the Novu dashboard and add a new template

Let's create a new template and call it "New bid in the system." let's drag a new "In-App" channel and add the following content:

{{name}} just added a bid of {{bid}}
Enter fullscreen mode Exit fullscreen mode

Once done, enter the Settings page, move to the API Keys tab, and copy the API KEY.

Let's add Novu to our project:

npm install @novu/node
Enter fullscreen mode Exit fullscreen mode

And let's add it to the top of our file and change the API_KEY with our API KEY from the Novu dashboard:

import { Novu } from "@novu/node";
const novu = new Novu("API_KEY");
Enter fullscreen mode Exit fullscreen mode

Now, when the host creates a new product, let's create a new topic, so let's modify our addProduct Function to look like this:

  const addProduct = async (product: string) => {
    "use server";

    const login = cookies().get("login");
    const { rows } =
      await sql`INSERT INTO bids (name, owner, total_bids) VALUES(${product}, ${login?.value!}, 0) RETURNING id`;
    await novu.topics.create({
      key: `bid-${rows[0].id}`,
      name: "People inside of a bid",
    });
    revalidatePath("/");
  };
Enter fullscreen mode Exit fullscreen mode

We have added a new novu.topics.create function, which creates a new topic.

The topic key must be unique.

We used the created ID of the bid to create the topic.

The name is anything that you want to understand what it is in the future.

So we have created a new topic. On a new bid, the only thing left is to register the user to the topic and notify everybody about it. Let's modify addBid and add the new logic:

const addBid = async (id: number, bid: number) => {
    "use server";

    const login = cookies().get("login");
    await sql`UPDATE bids SET total_bids = total_bids + ${bid} WHERE id = ${id}`;

    await novu.topics.addSubscribers(`bid-${id}`, {
      subscribers: [login?.value!],
    });

    await novu.trigger("new-bid-in-the-system", {
      to: [{ type: "Topic", topicKey: `bid-${id}` }],
      payload: {
        name: login?.value!,
        bid: bid,
      },
      actor: { subscriberId: login?.value! },
    } as ITriggerPayloadOptions);
    revalidatePath("/");
  };
Enter fullscreen mode Exit fullscreen mode

As you can see, we use novu.topics.addSubscribers to add the new user to the topic.

And then, we trigger the notification to the topic with novu.trigger to notify everybody about the new bid.

We also have the actor parameter, since we are already registered to the topic, we don't want to send a notification to ourselves. We can pass our identifier to the actor parameter to avoid that.

Only one thing is missing.

The host is clueless about what's going on.

The host is not registered to the topic and not getting any notifications.

We should send the host a notification on any bid.

So let's create a new template for that.

Now let's go over to the Novu dashboard and add a new template

Let's create a new template and call it "Host bid" Let's drag a new "In-App" channel and add the following content:

Congratulation!! {{name}} just added a bid of {{bid}}
Enter fullscreen mode Exit fullscreen mode

Now the only thing left is to call the trigger every time to ensure the host gets the notification. Here is the new code of addBid

const addBid = async (id: number, bid: number) => {
    "use server";
    // @ts-ignore
    const login = cookies().get("login");
    await sql`UPDATE bids SET total_bids = total_bids + ${bid} WHERE id = ${id}`;
    const { rows } = await sql`SELECT * FROM bids WHERE id = ${id}`;

    await novu.trigger("host-bid", {
      to: [
        {
          subscriberId: rows[0].owner,
        },
      ],
      payload: {
        name: login?.value!,
        bid: bid,
      },
    });

    await novu.topics.addSubscribers(`bid-${id}`, {
      subscribers: [login?.value!],
    });

    await novu.trigger("new-bid-in-the-system", {
      to: [{ type: "Topic", topicKey: `bid-${id}` }],
      payload: {
        name: login?.value!,
        bid: bid,
      },
      actor: { subscriberId: login?.value! },
    } as ITriggerPayloadOptions);
    revalidatePath("/");
  };
Enter fullscreen mode Exit fullscreen mode

Here is the full code of the page:

import Image from "next/image";
import { sql } from "@vercel/postgres";
import { cookies } from "next/headers";
import { NovuComponent } from "@biddingnew/components/novu.component";
import { NewProduct } from "@biddingnew/components/new.product";
import { revalidatePath } from "next/cache";
import { BidInput } from "@biddingnew/components/bid.input";
import { ITriggerPayloadOptions } from "@novu/node/build/main/lib/events/events.interface";
import { Novu } from "@novu/node";
const novu = new Novu("API_KEY");

export default async function Home() {
  const addBid = async (id: number, bid: number) => {
    "use server";
    // @ts-ignore
    const login = cookies().get("login");
    await sql`UPDATE bids SET total_bids = total_bids + ${bid} WHERE id = ${id}`;
    const { rows } = await sql`SELECT * FROM bids WHERE id = ${id}`;

    await novu.trigger("host-inform-bid", {
      to: [
        {
          subscriberId: rows[0].owner,
        },
      ],
      payload: {
        name: login?.value!,
        bid: bid,
      },
    });

    await novu.topics.addSubscribers(`bid-${id}`, {
      subscribers: [login?.value!],
    });

    await novu.trigger("new-bid-in-the-system", {
      to: [{ type: "Topic", topicKey: `bid-${id}` }],
      payload: {
        name: login?.value!,
        bid: bid,
      },
      actor: { subscriberId: login?.value! },
    } as ITriggerPayloadOptions);
    revalidatePath("/");
  };

  const addProduct = async (product: string) => {
    "use server";
    // @ts-ignore
    const login = cookies().get("login");
    const { rows } =
      await sql`INSERT INTO bids (name, owner, total_bids) VALUES(${product}, ${login?.value!}, 0) RETURNING id`;
    await novu.topics.create({
      key: `bid-${rows[0].id}`,
      name: "People inside of a bid",
    });
    revalidatePath("/");
  };

  const { rows } = await sql`SELECT * FROM bids ORDER BY id DESC`;

  // @ts-ignore
  const login = cookies().get("login");

  return (
    <div className="text-black container mx-auto p-4 border-l border-white border-r min-h-[100vh]">
      <div className="flex">
        <h1 className="flex-1 text-3xl font-bold mb-4 text-white">
          Product Listing ({login?.value!})
        </h1>
        <div>
          <NovuComponent user={login?.value!} />
        </div>
      </div>
      <NewProduct addProduct={addProduct} />
      <div className="grid grid-cols-3 gap-4">
        {rows.map((product) => (
          <div key={product.id} className="bg-white border border-gray-300 p-4">
            <div className="text-lg mb-2">
              <strong>Product Name</strong>: {product.name}
            </div>
            <div className="text-lg mb-2">
              <strong>Owner</strong>: {product.owner}
            </div>
            <div className="text-lg">
              <strong>Current Bid</strong>: {product.total_bids}
            </div>
            <div>
              <BidInput addBid={addBid} id={product.id} />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

And you are done πŸŽ‰


You can find the full source code of the project here:

https://github.com/novuhq/blog/tree/main/bidding-new

You can watch the full video of the same tutorial here:


Novu Hackathon is live!

The ConnectNovu Hackathon is live 🀩

This is your time to showcase your skills, meet new team members and grab awesome prizes.

If you love notifications, this Hackathon is for you.

You can create any system that requires notifications using Novu.

SMS, Emails, In-App, Push, anything you choose.

We have also prepared a list of 100 topics you can choose from - just in case you don't know what to do.

ConnectNovuSome fantastic prizes are waiting for you:

Such as GitHub sponsorships of $1500, Novu’s Swag, Pluralsight subscription, and excellent Novu benefits.

Top comments (6)

Collapse
 
nevodavid profile image
Nevo David • Edited

Did you try the new NextJS server actions?

Collapse
 
nevodavid profile image
Nevo David

Did you try the NextJS new app router?

Collapse
 
jamescalviniv profile image
jamescalviniv

Thanks for sharing, this was really insightful. My only problem is with the comment here
This is not a safe method, and you should send an encrypted id in the future. But it's ok for the example :)
I feel like you should've just taken the extra steps to cover how to do this the right way. It's cool you provided a link to those docs though. I also like the idea of vercel/postrgres, seemed kind of weird to have sql in the client at first but it grew on me by the end.

Collapse
 
nevodavid profile image
Nevo David

Thank you James!
I will make sure to cover this in the next video πŸŽ‰

Collapse
 
fruntend profile image
fruntend

Π‘ongratulations πŸ₯³! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up πŸ‘

Collapse
 
mezieb profile image
Okoro chimezie bright

Thanks for the dip dive.