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
Installing the project
We will start the project by initiating a new NextJS project:
npx create-next-app@latest
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
Let's go into the folder
cd new-proj
And modify our next.config.js to look like this
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true
}
}
module.exports = nextConfig;
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
);
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
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>
)
}
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;
- 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>
)
}
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`;
Let's also add our login cookie information
const login = cookies().get("login");
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>
);
}
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>
);
};
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("/");
};
- 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>
);
}
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>
);
};
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("/");
};
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>
);
}
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
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>
</>
);
};
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>
);
}
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:
- The host creates a new product - a topic is created.
- 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).
- 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).
- 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).
- 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}}
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
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");
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("/");
};
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("/");
};
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}}
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("/");
};
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>
);
}
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.
Some fantastic prizes are waiting for you:
Such as GitHub sponsorships of $1500, Novu’s Swag, Pluralsight subscription, and excellent Novu benefits.