TL;DR 🔥
In this tutorial, you'll learn how to build a blogging platform that let's you create and react to posts.
- We will build a login and registration with Hanko
- Build the entire blog:
- Create posts
- React to posts
- Add in-app notification to every reaction with Novu.
Novu: Open-source notification infrastructure 🚀
Just a quick background about us. Novu is an 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 and so on.
Let set it up 🆙
Here, I'll walk you through creating the project setup for the application. We'll use React.js for the front end and Node.js for the backend server.
Create a folder for the web application as done below.
mkdir simple-blog
cd simple-blog
mkdir client server
Setting up the Node.js server
Navigate into the server folder and create a package.json
file.
cd server & npm init -y
Install Express, Nodemon, and the CORS library.
npm install express cors nodemon
ExpressJS is a fast, minimalist framework that provides several features for building web applications in Node.js, CORS is a Node.js package that allows communication between different domains, and Nodemon is a Node.js tool that automatically restarts the server after detecting file changes.
Create an index.js
file - the entry point to the web server.
touch index.js
Set up a Node.js server using Express.js. The code snippet below returns a JSON object when you visit the http://localhost:4000/api
in your browser.
//👇🏻index.js
const express = require("express");
const cors = require("cors");
const app = express();
const PORT = 4000;
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cors());
app.get("/api", (req, res) => {
res.json({
message: "Hello world",
});
});
app.listen(PORT, () => {
console.log(`Server listening on ${PORT}`);
});
Configure Nodemon by adding the start command to the list of scripts in the package.json
file. The code snippet below starts the server using Nodemon.
//In server/package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon index.js"
},
Congratulations!🎉 You can now start the server by using the command below.
npm start
Setting up the React application
Navigate into the client folder via your terminal and create a new React.js project with Vite.
npm create vite@latest
Install React Icons and React Router - a JavaScript library that enables us to navigate between pages in a React application.
npm install react-router-dom react-icons
Delete the redundant files, such as the logo and the test files from the React app, and update the App.jsx
file to display “Hello World” as done below.
function App() {
return (
<div>
<p>Hello World!</p>
</div>
);
}
export default App;
Copy the CSS file required for styling the project here into the src/index.css
file.
Building the app user interface 🛠️
Here, we'll create the user interface for the blogging application to enable users to view, create, and react to posts.
Create a components folder within the client/src
folder containing the Home.jsx
, Login.jsx
, Details.jsx
, and NewPost.jsx
.
cd client/src
mkdir components
touch Home.jsx Details.jsx Login.jsx NewPost.jsx
- From the code snippet above
- The
Home.jsx
component displays all the available posts. - The
Detail.jsx
component displays the details of each post, such as its content, the date posted, and the number of reactions to the post. - The
NewPost.jsx
component enables users to create a new post. - The
Login.jsx
component log users into the application via Hanko.
- The
Update the App.jsx
file to render the components using React Router.
import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Home from "./components/Home";
import Details from "./components/Details";
import Login from "./components/Login";
import NewPost from "./components/NewPost";
const App = () => {
return (
<Router>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/login' element={<Login />} />
<Route path='/post/:slug' element={<Details />} />
<Route path='/post/new' element={<NewPost />} />
</Routes>
</Router>
);
};
export default App;
The Home page
The Home page displays all the posts created within the application. Copy the code below into the Home.jsx
file.
import React from "react";
import { Link } from "react-router-dom";
const Home = () => {
return (
<div>
<nav className='navbar'>
<Link to='/' className='logo'>
<h2>MyBlog</h2>
</Link>
<div style={{ display: "flex", alignItems: "center" }}>
<Link to='/post/new' className='newPostBtn'>
New Post
</Link>
</div>
</nav>
<main className='main'>
<h2 className='heading'>Latest Posts</h2>
<div className='posts_container'>
<Link to={`/post/details`} className='post'>
<h2 className='post_title'>
Building a chat app with React, Novu, and Websockets
</h2>
</Link>
<Link to={`/post/details`} className='post'>
<h2 className='post_title'>How to install Novu in React</h2>
</Link>
</div>
</main>
</div>
);
};
export default Home;
The Post Details page
This page displays post details when a user clicks on them from the Home.jsx
component. Copy the code below into the Details.jsx
file.
import React from "react";
import { AiTwotoneLike, AiTwotoneDislike } from "react-icons/ai";
const Details = () => {
return (
<div>
<header className='details_header'>
<h1 className='details_heading'>How to install Novu in React</h1>
<div className='post_details'>
<div>
<p className='details_date'>Posted on 30th July, 2023</p>
</div>
<div className='reactions-group'>
<button className='reactBtn'>
Like <AiTwotoneLike /> <span style={{ marginLeft: 5 }}>2</span>
</button>
<button className='reactBtn unlikeBtn'>
Dislike <AiTwotoneDislike />
<span style={{ marginLeft: 5 }}>1</span>
</button>
</div>
</div>
</header>
<main className='details_body'>
Lorem Ipsum is simply dummy text of the printing and typesetting
industry. Lorem Ipsum has been the industry's standard dummy text ever
since the 1500s, when an unknown printer took a galley of type and
scrambled it to make a type specimen book. It has survived not only five
centuries, but also the leap into electronic typesetting, remaining
essentially unchanged. It was popularised in the 1960s with the release
of Letraset sheets containing Lorem Ipsum passages, and more recently
with desktop publishing software like Aldus PageMaker including versions
of Lorem Ipsum.
</main>
</div>
);
};
export default Details;
The New Post page
This page displays a form field that accepts the title and content of a blog post. Copy the code snippet below into the NewPost.jsx
file.
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
const NewPost = () => {
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
console.log({ title, content });
setContent("");
setTitle("");
};
return (
<div>
<nav className='navbar'>
<Link to='/' className='logo'>
<h2>MyBlog</h2>
</Link>
<div>
<button className='newPostBtn logOut'>Log out</button>
</div>
</nav>
<main className='main'>
<h2 className='heading'>Create new post</h2>
<form className='newPost_form' onSubmit={handleSubmit}>
<label htmlFor='title' className='label'>
Title
</label>
<input
type='text'
className='newPost_title'
id='title'
name='title'
value={title}
required
onChange={(e) => setTitle(e.target.value)}
/>
<label htmlFor='content' className='label'>
Content
</label>
<textarea
rows={10}
className='newPost_content'
value={content}
required
onChange={(e) => setContent(e.target.value)}
/>
<button className='newPostBtn submitBtn' type='submit'>
Create Post
</button>
</form>
</main>
</div>
);
};
export default NewPost;
Are passkeys the future? 🔑
Hanko is an open-source, easy-to-integrate authentication solution that enables you to add various forms of authentication such as Email & Password, password-less, passkeys, and OAuth to your software applications.
It is an all-in-one authentication solution that enables you to set up authentication in a few minutes in your web applications. It also provides customisable web components which you can add to your web application to handle authentication quickly and easily.
In the upcoming sections, you'll learn how to add Hanko to the blogging application.
Adding authentication easily to React apps with Hanko
Here, you'll learn how to add authentication to your React applications using Hanko. Before we begin, install the Hanko package by running the code snippet below.
npm install @teamhanko/hanko-elements
Setting up an Hanko project
Visit the homepage and create an account.
Create a new organization that will manage your Hanko projects.
Then, create a new Hanko project and add your development server as the API URL.
Finally, save your API URL somewhere on your computer; it will be used for setting up the authentication.
Adding Hanko to React apps
Copy the code below into the Login.jsx
file.
import React, { useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { register, Hanko } from "@teamhanko/hanko-elements";
const hankoApi = "<YOUR_HANKO_API_URL>";
const Login = () => {
const navigate = useNavigate();
const hanko = useMemo(() => new Hanko(hankoApi), []);
useEffect(() => {
register(hankoApi).catch((error) => {
console.log(error);
});
}, []);
return (
<div className='login_container'>
<hanko-auth />
</div>
);
};
export default Login;
The code snippet displays the Hanko authentication component and enables users to sign up or sign in directly via Hanko.
Add the code snippet below within the Login.jsx
component.
//👇🏻 generates random string as ID
const generateUserID = () => Math.random().toString(36).substring(2, 10);
//👇🏻 executes after a user logs in
const redirectAfterLogin = useCallback(() => {
localStorage.setItem("loggedIn", "true");
if (!localStorage.getItem("u_id")) {
localStorage.setItem("u_id", generateUserID());
}
navigate("/");
}, [navigate]);
//👇🏻 triggered after a successful sign in
useEffect(
() =>
hanko.onAuthFlowCompleted(() => {
redirectAfterLogin();
}),
[hanko, redirectAfterLogin]
);
From the code snippet above, when a user signs into the application, the u_id
value is set to the local storage to identify each user when they request the Node.js server.
💡PS: I'm using local storage because this is a small application. If you are using Hanko in a production environment, you may need to check out the backend guide.
Congratulations!🎉 You've successfully added Hanko to a React application. In the upcoming section, we'll add all the necessary features to the blogging application.
Communicating with the Node.js server
In this section, you'll learn how to communicate with the Node.js server by retrieving and creating posts within the application.
Before we begin, create a utils
folder containing a util.js
file within the React app.
cd client
mkdir utils
cd utils
touch util.js
Displaying the blog posts
Create a posts array within the index.js
file on the server.
let posts = [
{
u_id: "a123",
post_id: "1",
title: "Building a chat app with NextJS and Novu",
slug: "building-a-chat-app-with-nextjs-and-novu",
content:
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.",
published_date: "27-07-2023",
likes: [{ u_id: "12345" }, { u_id: "ancsd" }],
dislikes: [{ user_id: "12345" }, { u_id: "12345" }],
},
{
u_id: "b123",
post_id: "2",
title: "How to create an ecommerce app with NextJS and Novu ",
slug: "how-to-create-an-ecommerce-app-with-nextjs-and-novu",
content:
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets.",
published_date: "27-07-2023",
likes: [{ u_id: "12345" }],
dislikes: [{ user_id: "12345" }],
},
];
Add another endpoint that returns the posts in JSON format.
app.get("/posts", (req, res) => {
res.json({
posts,
});
});
Next, create a function within the utils/util.js
file that sends a request to the endpoint from the React app.
export const fetchAllPosts = (setLoading, setPosts) => {
fetch("http://localhost:4000/posts")
.then((res) => res.json())
.then((data) => {
setLoading(false);
setPosts(data.posts);
})
.catch((err) => console.error(err));
};
Finally, execute the function when the Home component mounts.
import React, { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { fetchAllPosts } from "../utils/util";
const Home = () => {
const [loggedIn, setLoggedIn] = useState(false);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const fetchPosts = useCallback(() => {
fetchAllPosts(setLoading, setPosts);
}, []);
useEffect(() => {
if (localStorage.getItem("loggedIn")) {
setLoggedIn(true);
}
fetchPosts();
}, [fetchPosts]);
if (loading) return <p>Loading...</p>;
return (
<div>
<nav className='navbar'>
<Link to='/' className='logo'>
<h2>MyBlog</h2>
</Link>
<div style={{ display: "flex", alignItems: "center" }}>
{loggedIn ? (
<Link to='/post/new' className='newPostBtn'>
New Post
</Link>
) : (
<Link to='/login' className='newPostBtn'>
Log in
</Link>
)}
</div>
</nav>
<main className='main'>
<h2 className='heading'>Latest Posts</h2>
<div className='posts_container'>
{posts?.map((post) => (
<Link to={`/post/${post.slug}`} className='post' key={post.post_id}>
<h2 className='post_title'>{post.title}</h2>
</Link>
))}
</div>
</main>
</div>
);
};
export default Home;
The code snippet above fetches all the posts from the server when the page mounts and displays them within the React app. It also checks if the user is authenticated to display either the Login
or New Post
buttons.
Retrieving the posts' details
Here, you need to fetch a post's details when you click on it from the Home page. To do this, you need to filter the array of posts via its slug property.
Create another POST route that filters the posts
array by a post's slug and returns the entire post object.
app.post("/post/details", (req, res) => {
const { slug } = req.body;
const result = posts.filter((post) => post.slug === slug);
res.json({ post: result[0] });
});
Add a function within the utils/util.js
file that sends a request to the post/details
endpoint and returns the post object.
export const fetchPostContent = (slug, setLoading, setPost) => {
fetch("http://localhost:4000/post/details", {
method: "POST",
body: JSON.stringify({ slug: slug }),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
.then((res) => res.json(res))
.then((data) => {
setLoading(false);
setPost(data.post);
})
.catch((err) => console.error(err));
};
Import the function into the Details.jsx
component.
import { useParams } from "react-router-dom";
import { fetchPostContent } from "../utils/util";
const Details = () => {
const { slug } = useParams();
const [post, setPost] = useState({});
const [loading, setLoading] = useState(true);
const fetchPostDetails = useCallback(() => {
fetchPostContent(slug, setLoading, setPost)
}, [slug]);
useEffect(() => {
fetchPostDetails();
}, [fetchPostDetails]);
if (loading) return <p>Loading...</p>;
return (
<div>....</div>
)
Update the UI elements to display the post details accordingly.
return (
<div>
<header className='details_header'>
<h1 className='details_heading'>{post.title}</h1>
<div className='post_details'>
<div>
<p className='details_date'>Posted on {post.published_date}</p>
</div>
<div className='reactions-group'>
<button
className='reactBtn'
onClick={() => reactToPost(slug, "like")}
>
Like <AiTwotoneLike />{" "}
<span style={{ marginLeft: 5 }}>{post.likes.length}</span>
</button>
<button
className='reactBtn unlikeBtn'
onClick={() => reactToPost(slug, "dislike")}
>
Dislike <AiTwotoneDislike />
<span style={{ marginLeft: 5 }}>{post.dislikes.length}</span>
</button>
</div>
</div>
</header>
<main className='details_body'>{post.content}</main>
</div>
);
Reacting to blog posts
First, you need to create an endpoint on the Node.js server that updates the number of likes and dislikes property of a post when a user clicks the button from the user interface.
app.post("/post/react", async (req, res) => {
const { slug, type, u_id } = req.body;
//👇🏻 like post functionality
for (let i = 0; i < posts.length; i++) {
if (posts[i].slug === slug && type === "like") {
//👇🏻 validates the post reaction
const validateLike = posts[i].likes.filter(
(likes) => likes.u_id === u_id
);
if (validateLike.length === 0) {
posts[i].likes.push({ u_id });
res.json({ message: "You've just liked a post" });
}
}
//👇🏻 dislike post functionality
if (posts[i].slug === slug && type === "dislike") {
//👇🏻 validates the post reaction
const validateDislike = posts[i].dislikes.filter(
(dislikes) => dislikes.u_id === u_id
);
if (validateDislike.length === 0) {
posts[i].dislikes.push({ u_id });
const sendNotifcation = await notify("liked", u_id);
res.json({ message: "You've just disliked a post" });
}
}
}
});
The code snippet above handles the user's reaction to posts. It filters the posts
array via the post's slug and validates the post reaction to ensure that the user has not reacted to the post, before updating the property accordingly.
Create a function within the utils/util.js
file that sends a request to the endpoint when a user clicks the Like and Dislike buttons.
export const postReaction = (slug, type) => {
fetch("http://localhost:4000/post/react", {
method: "POST",
body: JSON.stringify({ slug, type, u_id: localStorage.getItem("u_id") }),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
.then((res) => res.json(res))
.then((data) => alert(data.message))
.catch((err) => console.error(err));
};
Execute the function when a user clicks on the buttons.
import { postReaction } from "../utils/util";
const Details = () => {
//👇🏻 calls the function
const reactToPost = (slug, type) => {
postReaction(slug, type);
};
return (
<div>
<header className='details_header'>
<h1 className='details_heading'>{post.title}</h1>
<div className='post_details'>
<div>
<p className='details_date'>Posted on {post.published_date}</p>
</div>
<div className='reactions-group'>
{/*-- like button*/}
<button
className='reactBtn'
onClick={() => reactToPost(slug, "like")}
>
Like <AiTwotoneLike />{" "}
<span style={{ marginLeft: 5 }}>{post.likes.length}</span>
</button>
{/*-- Dislike button*/}
<button
className='reactBtn unlikeBtn'
onClick={() => reactToPost(slug, "dislike")}
>
Dislike <AiTwotoneDislike />
<span style={{ marginLeft: 5 }}>{post.dislikes.length}</span>
</button>
</div>
</div>
</header>
<main className='details_body'>{post.content}</main>
</div>
);
};
export default Details;
Creating new posts
Create an endpoint that adds a new post to the posts
array.
//👇🏻 creates post slug
const createSlug = (text, id) => {
let slug = text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "");
slug = slug.replace(/\s+/g, "-");
return slug + "-" + id;
};
//👇🏻 generates a random string as ID
const generateID = () => Math.random().toString(36).substring(2, 10);
app.post("/post/add", (req, res) => {
const { u_id, title, content, date } = req.body;
const postObject = {
u_id,
post_id: generateID(),
title,
slug: createSlug(title, generateID()),
content,
published_date: date,
likes: [],
dislikes: [],
};
posts.unshift(postObject);
res.json({ message: "Post added successfully!✅" });
});
The code snippet above creates a new post object and adds the newly created post to the posts
array.
Add a function that sends a request to the endpoint within the utils/util.js
file.
export const addNewPost = (u_id, title, content, date, navigate) => {
fetch("http://localhost:4000/post/add", {
method: "POST",
body: JSON.stringify({ u_id, title, content, date }),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
.then((res) => res.json(res))
.then((data) => {
alert(data.message);
navigate("/");
})
.catch((err) => {
console.error(err);
alert("Encountered an error ❌");
});
};
Execute the function when the user submits the form within the NewPost.jsx
file.
//👇🏻 formates the date to a readable string
const formatDate = () => {
const date = new Date();
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}-${month}-${year}`;
};
//👇🏻 executes on form submit
const handleSubmit = (e) => {
e.preventDefault();
//👇🏻 adds the new post
addNewPost(
localStorage.getItem("u_id"),
title,
content,
formatDate(),
navigate
);
setContent("");
setTitle("");
};
Sending in-app notifications with Novu 📳
Here, we need to notify the post authors when someone reacts to their posts. To do this, we'll use Novu - an open-source notification infrastructure that enables you to send in-app, SMS, chat, push, and e-mail notifications from a single dashboard.
Creating a Novu project
Navigate into the client folder and create a Novu project by running the code below.
cd client
npx novu init
Select your application name and sign in to your Novu dashboard. The code snippet below contains the steps you should follow after running npx novu init
.
Now let's setup your account and send your first notification
? What is your application name? Forum App
? Now lets setup your environment. How would you like to proceed? Create a free cloud account (Recommended)
? Create your account with: Sign-in with GitHub
? I accept the Terms and Conditions (https://novu.co/terms) and have read the Privacy Policy (https://novu.co/privacy) Yes
✔ Created your account successfully.
Visit the demo page, copy your subscriber ID from the page, and click the Skip Tutorial button.
Create a notification template with a workflow as shown below:
Novu Digest allows you to control how you send notifications in your app. It collects multiple trigger events, schedules them, or sends them as a single message.
Update the In-App notification step to send this message to the post author when someone reacts to their post.
You have a new {{reaction}} on your post.
Adding Novu notification bell to a React app
Novu in-app notification uses a notification bell to send alerts to users. Here, you'll learn how to add it to your React applications.
Install the Novu Notification package.
npm install @novu/notification-center
Create a Novu.jsx
file within the components folder and copy the below into the file.
import React from "react";
import {
NovuProvider,
PopoverNotificationCenter,
NotificationBell,
} from "@novu/notification-center";
import { useNavigate } from "react-router-dom";
function Novu() {
const navigate = useNavigate();
const onNotificationClick = (notification) =>
navigate(notification.cta.data.url);
return (
<>
<NovuProvider
subscriberId='<YOUR_SUBSCRIBER_ID>'
applicationIdentifier='<YOUR_APP_ID>'
>
<PopoverNotificationCenter
onNotificationClick={onNotificationClick}
colorScheme='light'
>
{({ unseenCount }) => <NotificationBell unseenCount={unseenCount} />}
</PopoverNotificationCenter>
</NovuProvider>
</>
);
}
export default Novu;
The code snippet above enables us to add Novu's notification bell icon to the application. With this, you can view all the notifications within the app.
Select Settings on your Novu Admin Panel to copy your App ID and replace the subscriber's ID placeholder with yours.
Import the Novu.jsx
component into the Home.jsx
component.
const Home = () => {
return (
<div>
<nav className='navbar'>
<Link to='/' className='logo'>
<h2>MyBlog</h2>
</Link>
<div style={{ display: "flex", alignItems: "center" }}>
{/*---👇🏻 Novu component👇🏻---*/}
<Novu />
{loggedIn ? (
<Link to='/post/new' className='newPostBtn'>
New Post
</Link>
) : (
<Link to='/login' className='newPostBtn'>
Log in
</Link>
)}
</div>
</nav>
{/*--- other components ---*/}
</div>
);
};
Configuring Novu on a Node.js server
Install the Novu SDK for Node.js into the server folder.
npm install @novu/node
Import Novu from the package and create an instance using your API Key.
const { Novu } = require("@novu/node");
const novu = new Novu("<YOUR_API_KEY>");
Create a function within the index.js
file that sends notification to the post author via Novu.
const notify = async (reaction, userID) => {
await novu.subscribers.identify(userID, {
firstName: "inAppSubscriber",
});
const response = await novu.trigger("notify", {
to: {
subscriberId: "<YOUR_SUBSCRIBER_ID>",
},
payload: {
reaction,
},
});
return response.data.data;
};
Execute the function when a user reacts to a post.
app.post("/post/react", async (req, res) => {
const { slug, type, u_id } = req.body;
for (let i = 0; i < posts.length; i++) {
if (posts[i].slug === slug && type === "like") {
const validateLike = posts[i].likes.filter(
(likes) => likes.u_id === u_id
);
if (validateLike.length === 0) {
posts[i].likes.push({ u_id });
//👇🏻 Triggers Novu
const sendNotifcation = await notify("like", u_id);
if (sendNotifcation.acknowledged) {
res.json({ message: "You've just liked a post" });
}
}
}
if (posts[i].slug === slug && type === "dislike") {
const validateDislike = posts[i].dislikes.filter(
(dislikes) => dislikes.u_id === u_id
);
if (validateDislike.length === 0) {
posts[i].dislikes.push({ u_id });
//👇🏻 Triggers Novu
const sendNotifcation = await notify("dislike", u_id);
if (sendNotifcation.acknowledged) {
res.json({ message: "You've just disliked a post" });
}
}
}
}
});
Congratulations! You've completed the application.
Conclusion
So far, you've learnt how to authenticate users with Hanko, communicate between a React and Node.js app, and send in-app notifications using the Novu Digest.
Novu enables you to create a rich notification system in your applications, thereby providing a great user experience for your users. You should also try out Hanko - it is minimal and easy to integrate.
The source code for this tutorial is available here:
https://github.com/novuhq/blog/tree/main/hanko-auth-blog-with-novu.
Thank you for reading!