TL;DR
In this 2-part tutorial, I'll guide you through building a newsletter application that allows users to subscribe to a mailing list using a Google or GitHub account.
The application stores the user's details (email, first and last names) to Firebase, enabling the admin user to send notifications of beautifully designed email templates to every subscriber within the application using Novu.
You'll learn how to:
- implement Firebase Google, GitHub, and Email/Password authentication to your applications
- Save and fetch data from Firebase Firestore
- Create and modify email templates in the Novu Dev Studio
- Send email templates using the Novu
Building the application interface with Next.js
In this section, I'll walk you through building the user interface for the newsletter application.
First, let's set up a Next.js project. Open your terminal and run the following command:
npx create-next-app newsletter-app
Follow the prompts to select your preferred configuration settings. For this tutorial, we'll use TypeScript and the Next.js App Router for navigation.
Install the React Icons package, which allows us to use various icons within the application.
npm install react-icons
The application pages are divided into two parts - the subscriber routes and admin routes.
- Subscriber Routes:
-
/
- The application home page that displays GitHub and Google sign-in buttons for user authentication. -
/subscribe
- This page notifies users that they have successfully subscribed to the newsletter.
-
- Admin Routes:
-
/admin
- This route displays the admin sign-up and login buttons. -
/admin/register
- Here, admins can access the sign-up form to create a new admin account. -
/admin/login
- Existing admin users can log into the application using their email and password. -
/admin/dashboard
- Admin users can access this route to view all subscribers and manage the creation and sending of newsletters.
-
The Subscriber Routes
Copy the code snippet below into app/page.tsx
file. It displays the GiHub and Google sign-in buttons.
"use client";
import { useState } from "react";
import { FaGoogle, FaGithub } from "react-icons/fa";
export default function Home() {
const [loading, setLoading] = useState<boolean>(false);
const handleGoogleSignIn = () => {
setLoading(true);
//👉🏻 Firebase Google Sign in function
};
const handleGithubSignIn = () => {
setLoading(true);
//👉🏻 Firebase GitHub Sign in function
};
return (
<main className='flex h-screen flex-col p-8 items-center justify-center'>
<h2 className='text-3xl font-bold mb-6'>Sign up to the Newsletter</h2>
<button
className='py-3 px-6 flex items-center justify-center rounded-md border-gray-600 border-2 w-3/5 mb-4 text-lg hover:bg-gray-800 hover:text-gray-50'
disabled={loading}
>
<FaGithub className='inline-block mr-2' />
{loading ? "Signing in..." : "Sign Up with Github"}
</button>
<button
className='py-3 px-6 flex items-center justify-center rounded-md border-gray-600 border-2 w-3/5 mb-4 text-lg hover:bg-gray-800 hover:text-gray-50'
disabled={loading}
>
<FaGoogle className='inline-block mr-2' />
{loading ? "Signing in..." : "Sign Up with Google"}
</button>
</main>
);
}
Create a subscribe
folder within the app
directory. Inside the subscribe
folder, add a page.tsx
file and a layout.tsx
file.
cd app
mkdir subscribe && cd subscribe
touch page.tsx layout.tsx
To indicate that the user has successfully subscribed to the newsletter, copy and paste the following code snippet into the subscribe/page.tsx
file:
"use client";
export default function Subscribe() {
const handleSignOut = () => {
//👉🏻 signout the subscriber
};
return (
<main className='p-8 min-h-screen flex flex-col items-center justify-center'>
<h3 className='text-3xl font-bold mb-4'>You've Subscribed!</h3>
<button
className='bg-black text-gray-50 px-8 py-4 rounded-md w-[200px]'
onClick={handleSignOut}
>
Back
</button>
</main>
);
}
Finally, update the subscribe/layout.tsx
file with the following changes to set the page title to Subscribe | Newsletter Subscription
:
import type { Metadata } from "next";
import { Sora } from "next/font/google";
const sora = Sora({ subsets: ["latin"] });
//👇🏻 changes the page title
export const metadata: Metadata = {
title: "Subscribe | Newsletter Subscription",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={sora.className}>{children}</body>
</html>
);
}
The Admin Routes
Create an admin
folder within the app
directory. Inside the admin
folder, add a page.tsx
file to represent the Admin home page and a layout.tsx
file to display the page title.
cd app
mkdir admin && cd admin
touch page.tsx layout.tsx
Copy the code snippet below into the admin/page.tsx
file. It displays the links to the Admin Sign-up and Login pages.
import Link from "next/link";
export default function Admin() {
return (
<main className='flex min-h-screen flex-col items-center justify-center p-8'>
<h2 className='text-3xl font-bold mb-6'>Admin Panel</h2>
<Link
href='/admin/register'
className='py-3 px-6 flex items-center justify-center rounded-md border-gray-600 border-2 w-3/5 mb-4 text-lg hover:bg-gray-800 hover:text-gray-50'
>
Sign Up as an Admin
</Link>
<Link
href='/admin/login'
className='py-3 px-6 flex items-center justify-center rounded-md border-gray-600 border-2 w-3/5 mb-4 text-lg hover:bg-gray-800 hover:text-gray-50'
>
Log in as an Admin
</Link>
</main>
);
}
Update the page title by copying this code snippet into the admin/layout.tsx
file.
import type { Metadata } from "next";
import { Sora } from "next/font/google";
const sora = Sora({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Admin | Newsletter Subscription",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={sora.className}>{children}</body>
</html>
);
}
Next, you need to create the Admin Register, Login, and Dashboard pages. Therefore, create the folders containing a page.tsx
file.
cd admin
mkdir login register dashboard
cd login && page.tsx
cd ..
cd register && page.tsx
cd ..
cd dashboard && page.tsx
Within the register/page.tsx
file, copy and pasted the code snippet provided below. The code block displays the sign-up form where users can enter their email and password to create an admin user account.
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function Register() {
const [email, setEmail] = useState<string>("");
const [disabled, setDisabled] = useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setDisabled(true);
//👉🏻 admin sign up function
};
return (
<main className='flex min-h-screen flex-col items-center justify-center p-8'>
<h2 className='font-bold text-3xl text-gray-700 mb-3'>Admin Sign Up</h2>
<form
className='md:w-2/3 w-full flex flex-col justify-center'
onSubmit={handleSubmit}
>
<label htmlFor='email' className='text-lg'>
Email
</label>
<input
type='email'
id='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className='w-full py-3 px-6 border-gray-600 border-[1px] rounded-md mb-4'
/>
<label htmlFor='password' className='text-lg'>
Password
</label>
<input
type='password'
id='password'
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className='w-full py-3 px-6 border-gray-600 border-[1px] rounded-md mb-4'
/>
<button
className='py-3 px-6 rounded-md bg-black text-gray-50'
disabled={disabled}
>
Register
</button>
<p className='text-md mt-4 text-center'>
Already have an account?{" "}
<Link href='/admin/login' className='text-blue-500'>
Log in
</Link>
</p>
</form>
</main>
);
}
Update the login/page.tsx
file similarly to the admin register page. It accepts the user's email and password and logs the user into the application.
"use client";
import React, { useState } from "react";
import Link from "next/link";
export default function Login() {
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [disabled, setDisabled] = useState<boolean>(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setDisabled(true);
//👉🏻 log user into the application
};
return (
<main className='flex min-h-screen flex-col items-center justify-center p-8'>
<h2 className='font-bold text-3xl text-gray-700 mb-3'>Admin Login</h2>
<form
className='md:w-2/3 w-full flex flex-col justify-center'
onSubmit={handleSubmit}
>
<label htmlFor='email' className='text-lg'>
Email
</label>
<input
type='email'
id='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder='test@gmail.com'
required
className='w-full py-3 px-6 border-gray-600 border-[1px] rounded-md mb-4'
/>
<label htmlFor='password' className='text-lg'>
Password
</label>
<input
type='password'
id='password'
required
placeholder='test123'
value={password}
onChange={(e) => setPassword(e.target.value)}
className='w-full py-3 px-6 border-gray-600 border-[1px] rounded-md mb-4'
/>
<button
className='py-3 px-6 rounded-md bg-black text-gray-50'
disabled={disabled}
>
Log in
</button>
<p className='text-md mt-4 text-center'>
Don't have an account?{" "}
<Link href='/admin/register' className='text-blue-500'>
Create one
</Link>
</p>
</form>
</main>
);
}
Finally, let's create the admin dashboard page. It displays all the available subscribers and a form enabling admin users to create and view the existing newsletters.
Copy the code snippet below into the dashboard/page.tsx
file.
"use client";
import { useState } from "react";
import Link from "next/link";
import Newsletters from "@/components/Newsletters";
import SubscribersList from "@/components/SubscribersList";
type Subscriber = {
id: string;
data: {
email: string;
firstName: string;
lastName: string;
topics: string[];
};
};
export default function Dashboard() {
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
const [toggleView, setToggleView] = useState<boolean>(false);
const handleToggleView = () => setToggleView(!toggleView);
const fetchAllSubscribers = async () => {
//👉🏻 fetch all subscribers
};
const handleLogout = () => {
//👉🏻 sign user out
};
return (
<div className='flex w-full min3-h-screen relative'>
<div className='lg:w-[15%] border-r-2 lg:flex hidden flex-col justify-between min-h-[100vh]'>
<nav className='fixed p-4 pb-8 flex flex-col justify-between h-screen'>
<div className='flex flex-col space-y-4'>
<h3 className='text-2xl font-bold text-blue-500 mb-6'>Dashboard</h3>
<Link
href='#subscribers'
onClick={handleToggleView}
className={`${
toggleView ? "" : "bg-blue-400 text-blue-50"
} p-3 mb-2 rounded-md`}
>
Subscribers
</Link>
<Link
href='#newsletters'
onClick={handleToggleView}
className={`${
!toggleView ? "" : "bg-blue-400 text-blue-50"
} p-3 mb-2 rounded-md`}
>
Newsletters
</Link>
</div>
<button className='text-red-500 block mt-10' onClick={handleLogout}>
Log out
</button>
</nav>
</div>
<main className='lg:w-[85%] w-full bg-white h-full p-4'>
<div className='flex items-center lg:justify-end justify-between mb-3'>
<Link
href='#'
onClick={handleToggleView}
className='lg:hidden block text-blue-700'
>
{!toggleView ? "View Newsletters" : "View Subscribers"}
</Link>
<button
className='bg-red-500 text-white px-5 py-3 rounded-md lg:hidden block'
onClick={handleLogout}
>
Log Out
</button>
</div>
<div>
{toggleView ? (
<Newsletters />
) : (
<SubscribersList subscribers={subscribers} />
)}
</div>
</main>
</div>
);
}
From the code snippet above, we have two components: the Newsletters
and the SubscribersList
.
The Newsletters
component renders the existing newsletters and a form that allows the admin to create new ones. The SubscribersList
component displays all the existing subscribers within the application.
Therefore, create a components
folder that contains the Newsletters
and SubscribersList
components within the app
folder.
cd app
mkdir components && cd components
touch SubscribersList.tsx Newsletters.tsx
Copy the code snippet below into the SubscribersList.tsx
. It accepts the existing subscribers' data as a prop and renders it within a table.
"use client";
type Subscriber = {
id: string;
data: {
email: string;
firstName: string;
lastName: string;
topics: string[];
};
};
export default function SubscribersList({
subscribers,
}: {
subscribers: Subscriber[];
}) {
return (
<main className='w-full'>
<section className='flex items-center justify-between mb-4'>
<h2 className='font-bold text-2xl mb-3'>All Subscribers</h2>
</section>
<div className='w-full'>
<table className='w-full'>
<thead>
<tr>
<th className='py-3'>First Name</th>
<th className='py-3'>Last Name</th>
<th className='py-3'>Email Address</th>
</tr>
</thead>
<tbody>
{subscribers.map((subscriber) => (
<tr key={subscriber.id}>
<td className='py-3'>{subscriber.data.firstName}</td>
<td className='py-3'>{subscriber.data.lastName}</td>
<td className='py-3'>{subscriber.data.email}</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
Lastly, update the Newsletters.tsx
component to display the newsletter creation form and display the existing newsletters.
"use client";
import React, { useEffect, useState } from "react";
export default function Newsletters() {
const [recipients, setRecipients] = useState<string[]>([]);
const [subject, setSubject] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [disabled, setDisable] = useState<boolean>(false);
const [subscribers, setSubscribers] = useState<SubscribersData[]>([]);
const [newsLetters, setNewsletters] = useState<string[]>([]);
const handleSendNewsletter = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
emailNewsletter();
};
const emailNewsletter = async () => {
//👉🏻 send newsletter
};
const fetchRecipients = async () => {
//👉🏻 fetch all subscribers
};
const fetchNewsLetters = async () => {
//👉🏻 fetch all newsletters
};
useEffect(() => {
fetchRecipients();
fetchNewsLetters();
}, []);
return (
<main className='w-full'>
<h2 className='font-bold text-2xl mb-4'>Create Campaign</h2>
<form onSubmit={handleSendNewsletter} className='mb-8'>
<label htmlFor='subject'>Subject</label>
<input
type='text'
id='subject'
value={subject}
required
onChange={(e) => setSubject(e.target.value)}
className='w-full px-4 py-3 border-[1px] border-gray-600 rounded-sm mb-3'
/>
<label htmlFor='recipients'>Recipients</label>
<input
type='text'
id='recipients'
className='w-full px-4 py-3 border-[1px] border-gray-600 rounded-sm mb-3'
disabled
readOnly
value={recipients.join(", ")}
/>
<label htmlFor='message'>Message</label>
<textarea
id='message'
rows={5}
value={message}
required
onChange={(e) => setMessage(e.target.value)}
className='w-full px-4 py-3 border-[1px] border-gray-600 rounded-sm'
></textarea>
<button
className='bg-blue-500 text-white py-3 px-6 rounded my-3'
disabled={disabled}
>
{disabled ? "Sending..." : "Send Newsletter"}
</button>
</form>
<h2 className='font-bold text-2xl '>Recent Newsletters</h2>
<div className='flex flex-col gap-4'>
{newsLetters.map((item, index) => (
<div
className='flex justify-between items-center bg-gray-100 p-4'
key={index}
>
<h3 className='font-bold text-md'>{item}</h3>
<button className='bg-green-500 text-gray-50 px-4 py-2 rounded-md'>
Sent
</button>
</div>
))}
</div>
</main>
);
}
Congratulations!🥳 You've successfully completed the user interface for the application.
In the upcoming section, you'll learn how to connect the application to a Firebase backend and effectively manipulate data within the application.
How to add Firebase authentication to a Next.js application
In this section, you'll learn how to implement multiple authentication methods using Firebase within your application.
Subscribers will have the option to subscribe or sign in using either a Google or GitHub account, while Admin users will be able to sign in using the Email and Password authentication method.
Setting up a Firebase project
Visit the Firebase console and sign in with a Gmail account.
Create a Firebase project and select the </>
icon to create a new Firebase web app.
Provide your app name and register it.
Install the Firebase SDK in your Next.js project by running the code snippet below.
npm install firebase
Create a firebase.ts
file at the root of your Next.js project and copy the Firebase configuration code for your app into the file.
import { initializeApp, getApps } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth, GoogleAuthProvider, GithubAuthProvider } from "firebase/auth";
const firebaseConfig = {
apiKey: "******",
authDomain: "**********",
projectId: "********",
storageBucket: "******",
messagingSenderId: "**********",
appId: "********",
measurementId: "********",
};
//👇🏻 gets the app config
const app =
getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
//👇🏻 creates a Firebase Firestore instance
const db = getFirestore(app);
//👇🏻 creates a Firebase Auth instance
const auth = getAuth(app);
//👇🏻 creates a Google & GitHub Auth instance
const googleProvider = new GoogleAuthProvider();
const githubProvider = new GithubAuthProvider();
//👇🏻 export them for use within the application
export { db, auth, googleProvider, githubProvider };
Before you can add Firebase Authentication to your application, you need to set it up in your Firebase console.
To do this, navigate to the left-hand side panel, select Build
, and then click on Authentication
to add Firebase Authentication to your project.
Lastly, enable the Google and Email/Password authentication methods.
Add Google authentication to Next.js
First, create a util.ts
file within the app
folder and copy the provided code snippet into the file.
import { signInWithPopup } from "firebase/auth";
import { auth } from "../../firebase";
//👇🏻 Split full name into first name and last name
export const splitFullName = (fullName: string): [string, string] => {
const [firstName, ...lastNamePart] = fullName.split(" ");
return [firstName, lastNamePart.join(" ")];
};
//👇🏻 Handle Sign in with Google and Github
export const handleSignIn = (provider: any, authProvider: any) => {
signInWithPopup(auth, provider)
.then((result) => {
const credential = authProvider.credentialFromResult(result);
const token = credential?.accessToken;
if (token) {
const user = result.user;
const [first_name, last_name] = splitFullName(user.displayName!);
console.log([first_name, last_name, user.email]);
}
})
.catch((error) => {
const errorCode = error.code;
const errorMessage = error.message;
console.error({ errorCode, errorMessage });
alert(`An error occurred, ${errorMessage}`);
});
};
The code snippet enables users to sign into the application via Google or GitHub authentication. It accepts authentication providers as parameters and logs the user's email, first name, and last name to the console after a successful authentication process.
Next, you can execute the handleSignIn
function within the app/page.tsx
file when a user clicks the Sign Up with Google or GitHub buttons.
import { GoogleAuthProvider, GithubAuthProvider } from "firebase/auth";
import { googleProvider, githubProvider } from "../../firebase";
import { handleSignIn } from "./util";
const handleGoogleSignIn = () => {
setLoading(true);
handleSignIn(googleProvider, GoogleAuthProvider);
};
const handleGithubSignIn = () => {
setLoading(true);
handleSignIn(githubProvider, GithubAuthProvider);
};
Add GitHub authentication to Next.js
Before the GitHub authentication method can work as expected, you need to create a GitHub OAuth App.
Once you've created the GitHub OAuth App, copy the client ID and client secret from the app settings. You'll need these credentials to activate the GitHub authentication method within the application.
Go back to the Firebase Console. Enable the GitHub sign-in method and paste the client ID and secret into the input fields provided. Additionally, add the authorisation callback URL to your GitHub app.
Congratulations! You've successfully added GitHub and Google authentication methods to the application. Next, let's add the Email/Password authentication for Admin users.
Add email and password authentication to Next.js
Before we proceed, ensure you have enabled the email/password sign-in method within the Firebase project. Then, execute the functions below when a user signs up and logs in as an admin user.
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
} from "firebase/auth";
import { auth } from "../../firebase";
//👇🏻 Admin Firebase Sign Up Function
export const adminSignUp = async (email: string, password: string) => {
try {
const userCredential = await createUserWithEmailAndPassword(
auth,
email,
password
);
const user = userCredential.user;
if (user) {
//👉🏻 sign up successful
}
} catch (e) {
console.error(e);
alert("Encountered an error, please try again");
}
};
//👇🏻 Admin Firebase Login Function
export const adminLogin = async (email: string, password: string) => {
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
const user = userCredential.user;
if (user) {
//👉🏻 log in successful
}
} catch (e) {
console.error(e);
alert("Encountered an error, please try again");
}
};
The provided code snippets include functions for admin user sign-up (adminSignUp
) and login (adminLogin
) using Firebase authentication. You can trigger these functions when a user signs up or logs in as an admin user.
You can sign users (admin and subscribers) out of the application using the code snippet below.
import { signOut } from "firebase/auth";
import { auth } from "../../firebase";
//👇🏻 Firebase Logout Function
export const adminLogOut = async () => {
signOut(auth)
.then(() => {
//👉🏻 sign out successful
})
.catch((error) => {
console.error({ error });
alert("An error occurred, please try again");
});
};
Congratulations! You've completed the authentication process for the application. You can read through the concise documentation if you encounter any issues.
How to interact with Firebase Firestore
In this section, you'll learn how to save and retrieve data from Firebase Firestore by saving newsletters, subscribers, and admin users to the database.
Before we proceed, you need to add the Firebase Firestore to your Firebase project.
Create the database in test mode, and pick your closest region.
After creating your database, select Rules
from the top menu bar, edit the rules, and publish the changes. This enables you to make requests to the database for a longer period of time.
Congratulations!🎉 Your Firestore database is ready.
Saving subscribers’ data to the database
After a user subscribes to the newsletter, you need to save their first name, last name, email, and user ID to Firebase. To do this, execute the saveToFirebase
function after a subscriber successfully signs in via the GitHub or Google sign-in method.
import { auth, db } from "../../firebase";
import { addDoc, collection, getDocs } from "firebase/firestore";
export type SubscribersData = {
firstName: string;
lastName: string;
email: string;
id: string;
};
//👇🏻 Save the subscriber data to Firebase
const saveToFirebase = async (subscriberData: SubscribersData) => {
try {
//👇🏻 checks if the subscriber already exists
const querySnapshot = await getDocs(collection(db, "subscribers"));
querySnapshot.forEach((doc) => {
const data = doc.data();
if (data.email === subscriberData.email) {
window.location.href = "/subscribe";
return;
}
});
//👇🏻 saves the subscriber details
const docRef = await addDoc(collection(db, "subscribers"), subscriberData);
if (docRef.id) {
window.location.href = "/subscribe";
}
} catch (e) {
console.error("Error adding document: ", e);
}
};
The saveToFirebase
function validates if the user is not an existing subscriber to prevent duplicate entries before saving the subscriber's data to the database.
To differentiate between existing subscribers and admin users within the application, you can save admin users to the database.
Execute the provided function below when a user signs up as an admin.
import { db } from "../../firebase";
import { addDoc, collection } from "firebase/firestore";
//👇🏻 Add Admin to Firebase Database
const saveAdmin = async (email: string, uid: string) => {
try {
const docRef = await addDoc(collection(db, "admins"), { email, uid });
if (docRef.id) {
alert("Sign up successful!");
window.location.href = "/admin/dashboard";
}
} catch (e) {
console.error("Error adding document: ", e);
}
};
Next, update the admin log-in function to ensure that the user's data exists within the admin
collection before granting access to the application.
import { auth, db } from "../../firebase";
import { addDoc, collection, getDocs, where, query } from "firebase/firestore";
//👇🏻 Admin Firebase Login Function
export const adminLogin = async (email: string, password: string) => {
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
const user = userCredential.user;
//👇🏻 Email/Password sign in successful
if (user) {
//👇🏻 check if the user exists within the database
const q = query(collection(db, "admins"), where("uid", "==", user.uid));
const querySnapshot = await getDocs(q);
const data = [];
querySnapshot.forEach((doc) => {
data.push(doc.data());
});
if (data.length) {
window.location.href = "/admin/dashboard";
alert("Log in successful!");
}
}
} catch (e) {
console.error(e);
alert("Encountered an error, please try again");
}
};
To ensure that only authenticated users can access the Admin dashboard, Firebase allows you to retrieve the current user's data at any point within the application. This enables us to protect the Dashboard page from unauthorised access and listen to changes in the user's authentication state.
import { onAuthStateChanged } from "firebase/auth";
import { auth } from "../firebase";
export default function Dashboard() {
const router = useRouter();
useEffect(() => {
onAuthStateChanged(auth, (user) => {
if (!user) {
//👉🏻 redirect user to log in form
router.push("/");
}
});
}, [router]);
}
Congratulations on making it thus far! 🎉 In the upcoming sections, you'll learn how to create and send beautifully designed newsletters to subscribers using Novu Echo and React Email.
Top comments (4)
Great tutorial!
@empe this looks amazing! Thanks!
That's how devrel is done @empe awesome work :) super knowledgeable
Awesome guide with great level of detail!