How to Create a Secure Newsletter Subscription with NextJS, Supabase, Nodemailer and Arcjet đđŻ
In today's digital age, newsletters are a powerful tool for engaging with your audience, driving traffic, and building a loyal community around your brand.
Enterprise options can be costly for large lists, while free solutions often face spam sign-ups, poor email validation, and inadequate bot protection.
In this tutorial, we will build a custom email subscription app from scratch and solve the common challenges associated with existing solutions by integrating robust security measures and validation techniques.
Hereâs an overview of the tech stack and features:
NextJS-based user interface and API for capturing user emails with a sleek and responsive design;
Supabase-powered backend to store and manage subscriber data securely;
Nodemailer transporter for sending confirmation emails, enabling a smooth double opt-in process;
Arcjet integration for shield protection, bot detection, rate limiting, and email validation.
By the end of this guide, you'll have a fully functional and secure newsletter subscription form. I hope this tutorial will be useful for you, and that you'll learn a few security-related concepts along the way.
Thanks to the Arcjet team for sponsoring this article! They contacted me to ask if I would like to try their beta and paid me for my time but did not influence the content.
What is Arcjet?
Arcjet is a security solution designed to empower developers by providing robust application protection with minimal effort.
Arcjet is seamlessly integrated into your application as a dependency, requiring no additional agents or complex infrastructure.
With Arcjet, you can easily implement critical security features such as rate limiting, bot protection, email validation, and defenses against common web attacks.
Currently, Arcjet is optimized for the JavaScript ecosystem, offering robust support for Bun, NextJS, Node.js, and SvelteKit applications.
The team is also working on expanding support to other popular frameworks, including Django, Ruby on Rails, and Laravel, which will be available in the future.
Also, Arcjet is available on GitHub, so feel free to check them out and give it a â star if you liked the project.
How are we going to use Arcjet?
We'll leverage Arcjet to implement four major security features:
1. Validating Email Format
We will use email format validation to make sure only correctly formatted email addresses can be added to our subscriber list.
Arcjetâs email validation feature will be used server-side to check for common issues like invalid formats, disposable emails, and emails without MX records.
2. Adding Rate Limit
We will implement rate limiting on our API to manage the server load via Arcjetâs rate-limiting rules, such as a fixed window.
For example, we will restrict the number of submissions per IP address within a certain time frame, preventing abuse and reducing the server load.
3. Detecting Bots
We will also protect the subscription form from automated sign-ups via Arcjetâs bot detection feature to distinguish between human users and bots.
This will help us block or challenge suspicious requests, ensuring that the mailing list consists of genuine subscribers.
4. Enabling Attack Protection
To protect the app against common web attacks, such as DDoS or brute force, weâll use Arcjetâs attack protection mechanisms.
This includes features like IP blocking, request filtering, and anomaly detection.
Planning the User Interface
There will be four UI views that will be switched between the subscription state: the landing screen, pending screen, error screen, and confirmation screen:
1. Landing screen
The landing screen will serve as the initial entry point with a simple form featuring an email text field and a submit button.
The design will be designed to encourage users to subscribe to the newsletter.
2. Pending screen
Once users submit their email addresses, they will be redirected to the pending screen. It will reassure users that the subscription process is in progress.
This page will notify them to check their email.
3. Error screen
In the event of an issue during the subscription process, such as an invalid email or a rate limit, users will be presented with an error view.
This page will display a message indicating that the subscription has failed.
4. Confirmation screen
Upon successful subscription, users will be re-directed to the confirmation screen, with a message that their email has been registered.
This will let the users know that the subscription process is complete.
Setting up the NextJS app
Open your terminal and run npx create-next-app@latest
.
This will launch the built-in setup wizard. Give your project a name arcjet-demo
and configure the rest of the options as shown below:
Change the working directory to the newly created project by running the command cd arcjet-demo
.
Start the developer server by running npm run dev
.
Click on the link provided in the terminal. It should open the NextJS welcome screen in your default web browser.
You should presented with something like this:
If your app screen does not open from the terminal link, you can open it in your browser manually by navigating to http://localhost:3000.
Adding media assets
For this tutorial, you will need 5 icons.
Four will represent the newsletter app state for the unsubscribed, approval, subscribed, and error state and the fifth will represent the loading state.
I recommend using static PNG files with a transparent background and an animated GIF to improve the UX while switching through the states.
Name the files unsubsribed.png
, approval.png
, subscribed.png
, error.png
and loading.gif
so it would be easier to follow along with the tutorial.
You can pick any icons that fit your style and app design. For reference, I used assets available on Flaticon and Loading.io.
Download them and add them to the public
folder in your NextJS project.
Creating the Arcjet account
Visit the Arcjet website and create a free account.
You can sign up via GitHub or Google.
Create a new project, by clicking on the "+ New site" at the profile dashboard.
Enter the project name arcjet-demo
and click "Create".
You should now be presented with the Arcjet API key.
Copy it to your clipboard, since we will need it in a few minutes.
Adding Arcjet to NextJS
Switch to your NextJS project in the code editor and navigate to the root.
Install Arcjet by running npm i @arcjet/next
in your terminal.
Create a new file called .env.local
in the root of your project and include the Arcjet API key as shown below:
ARCJET_KEY=yourarcjetapikeygoeshere
Next, we will create an Arcjet configuration file so it will be easier to work with Arcjet later in the tutorial.
Make a new folder lib
in the app
folder.
Create a new file arcjet.tsx
inside the newly created lib
folder.
Include the following code in the arcjet.tsx
file:
import arcjet, {
shield,
detectBot,
fixedWindow,
validateEmail,
} from "@arcjet/next";
export default arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({
mode: "LIVE",
allow: [],
}),
fixedWindow({
mode: "LIVE",
match: "/api/submit",
window: "2m",
max: 3,
}),
validateEmail({
mode: "LIVE",
block: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS"],
}),
],
});
First, we imported arcjet
library and protection-related functions shield
, detectBot
, validateEmail
, fixedWindow
from the @arcjet/next
package.
Then we set the key
variable using the ARCJET_KEY
from the .env.local
file.
Next, we configured the Arcjet rules as follows:
shield
to protect against general threats;detectBot
to detect and block automated bot traffic;fixedWindow
to limit the number of requests to/api/submit
within a specific time window (max 3 requests in 2 minutes);validateEmail
to block requests with disposable, invalid, or emails without MX records.
We configured each instance to be used in the LIVE
mode to block the requests if the warnings are detected. If you want them to be logged into the console, you can use DRY_RUN
mode, which is recommended for testing or debugging.
Setting up Nodemailer transporter
Install the Nodemailer by running npm i nodemailer && npm i @types/nodemailer --save-dev
in your terminal.
To work with Nodemailer, we must first add and configure the following SMTP keys in our local.env
in the project root as follows:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=yourusername
SMTP_PASS=yourpassword
BASE_URL=http://localhost:3000 # or your production URL
We will use Gmail as an SMTP provider for this tutorial, but if you use a different provider check their docs.
For the SMTP_USER
key provide your Gmail email address.
For the SMTP_PASS
visit your Google account for app keys and create a new password for this demo. You can access the configuration page here.
To keep the configuration logic separate from the API routes, we will include the configuration file in a seperate library, just like we did for Arcjet before.
Navigate to your lib
folder and create a new file nodemailer.tsx
inside of it.
Include the following code in the nodemailer.tsx
file:
import nodemailer from "nodemailer";
export const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST!,
port: parseInt(process.env.SMTP_PORT!, 10),
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
});
export const sendOptInEmail = async (email: string, token: string) => {
const mailOptions = {
from: '"Newsletter-subscription" <noreply@youremail.com>',
to: email,
subject: "Confirm Your Subscription",
html: `<h1 style="font-size: 1.5rem; font-weight: 700;">Thanks for signing up!</h1></p>
<p style="font-size: 1.125rem; color: #4a5568;">Please confirm your subscription by clicking on the link below:</p>
<p><a href="${process.env.BASE_URL}/api/confirm?token=${token}" style="display: block; width: 100%; font-size: 16px; background-color: #38b2ac; color: #ffffff; padding: 0.5rem; border-radius: 0.25rem; text-align: center; text-decoration: none; transition: background-color 0.2s;">Confirm Subscription</a></p>`,
};
await transporter.sendMail(mailOptions);
};
First, we imported the nodemailer
package that will handle the logic for sending the emails to provide the double opt-in for our newsletter demo app.
Then we created the transporter
object, which holds all of the configuration data from the .local.env
file to initialize the nodemailer
.
Next, we defined the sendOptInEmail
function that will hold the email structure of the double opt-in confirmation email sent to the subscriber.
The sendOptInEmail
takes in two variables email
and token
. We will pass those after we import the library in the API route.
We also used a simple HTML structure for the body of the email with some CSS styling so it would look similar to our app design.
Creating Supabase tables
Navigate to the Supabase website and log in.
Click "New project" in the user dashboard.
Give your app a name arcjet-demo
, provide a strong password, and select a region closest to the location of your users for optimal performance.
Next, we will create a couple of tables to store the data.
The first table will store the unique token that will be used to identify the user once the user clicks on the verification link for the double opt-in.
Navigate to the "Database" section on the left navigation sidebar and click "New table" on the right of the dashboard to create a new database.
We will call the first table pending_subscriptions
to temporarily store the email addresses that have been submitted but have not been approved.
We will create three columns in the table.
The first column will store the user token
with the type of uuid
, the default value of get_random_uuid()
, and serve as the primary key.
The second column will store the email
address with the type of text
.
The third column will store link_sent_at
data with the type of timestamptz
. Select the default value to be set as now()
. It will detect when the confirmation email is sent.
Now let's create the second table that will store the confirmed email addresses of users. We will move the user to it upon successful confirmation.
While on the "Database" page click "New table" again and this time we will call the table subscribers
.
We will create two columns for this table.
The first column will be called email
. Set the text
type for this column, and make it a primary column. It will store all of the confirmed email addresses.
The second column will be called confirmed_at
. Set the type of it as timestamptz
. Also, select the default value to be set as now()
. This will store the time when the user completes the subscription process.
Adding Supabase to NextJS
Switch back to your NextJS project and run the following command in your terminal to install Supabase: npm i @supabase/supabase-js
.
Also, install a UUID library to generate the unique tokens that we will need for email confirmation by running npm i uuid && npm i @types/uuid --save-dev
.
Next, add the following variables to .env.local
file:
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
To find these variables, visit the Supabase website, open the main dashboard of your project, click "Settings" at the bottom left, and select "API".
Switch back to your NextJS project and create a new file called supabase.tsx
inside the lib
folder. We will use it to create a dedicated client for the configuration setup.
Include the following code in the supabase.tsx
file:
import { SupabaseClient } from "@supabase/supabase-js";
const supabase = new SupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_KEY!
);
export default supabase;
We first imported SupabaseClient
from the @supabase/supabase-js
package.
To initialize the client, we provided the Supabase project link and the API key from the .local.env
file and then exported the client so we could later use it when handling the API route logic.
Creating a Submit API route
This route will handle the logic of the user request after the email has been entered in the input area and the submit button has been pressed.
First, navigate to the app
directory in your NextJS project.
Next, create a new api
folder, and within it, add another folder called submit
. Then, open the submit
folder and create a new file named route.tsx
.
Include the following code in the route.tsx
:
import { NextRequest, NextResponse } from "next/server";
import arcjet from "@/lib/arcjet";
import { sendOptInEmail } from "@/lib/nodemailer";
import supabase from "@/lib/supabase";
import { v4 as uuidv4 } from "uuid";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const email = searchParams.get("email")!;
const decision = await arcjet.protect(req, { email });
if (decision.isDenied()) {
if (decision.reason.isShield()) {
console.log("Suspicious action detected!");
return NextResponse.json(
{ error: "Suspicious action detected!" },
{ status: 403 }
);
}
if (decision.reason.isBot()) {
console.log("Looks like you might be a bot!");
return NextResponse.json(
{ error: "Looks like you might be a bot!" },
{ status: 403 }
);
}
}
return NextResponse.json({ data: "Hello World!" });
}
export async function POST(req: NextRequest) {
const data = await req.json();
const email = data.email;
const decision = await arcjet.protect(req, { email });
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
const resetTime = decision.reason.resetTime;
if (!resetTime) {
return NextResponse.json(
{ error: "Too many requests. Try again later." },
{ status: 429 }
);
}
const remainingTime = Math.max(
0,
Math.floor((resetTime.getTime() - Date.now()) / 1000)
);
const timeUnit = remainingTime > 60 ? "minutes" : "seconds";
const timeValue =
timeUnit === "minutes" ? Math.ceil(remainingTime / 60) : remainingTime;
return NextResponse.json(
{ error: `Too many requests. Try again in ${timeValue} ${timeUnit}.` },
{ status: 429 }
);
}
if (decision.reason.isEmail()) {
const errorType = decision.reason.emailTypes;
if (errorType.includes("INVALID")) {
return NextResponse.json(
{ error: "Invalid email format. Check your spelling." },
{ status: 400 }
);
} else if (errorType.includes("DISPOSABLE")) {
return NextResponse.json(
{ error: "Disposable email address. Check your spelling." },
{ status: 400 }
);
} else if (errorType.includes("NO_MX_RECORDS")) {
return NextResponse.json(
{ error: "Email without an MX record. Check your spelling." },
{ status: 400 }
);
} else {
return NextResponse.json(
{ error: "Invalid email. Check your spelling." },
{ status: 400 }
);
}
}
}
const checkEmailExists = async (email: string) => {
const { data, error } = await supabase
.from("subscribers")
.select("*")
.eq("email", email);
if (error) {
console.error("Error checking email:", error);
return false;
}
return data.length > 0;
};
const handleEmailSubmission = async (email: string) => {
const emailExists = await checkEmailExists(email);
if (emailExists) {
return NextResponse.json(
{ error: "This email has already been registered." },
{ status: 500 }
);
} else {
const token = uuidv4();
const { error } = await supabase
.from("pending_subscriptions")
.insert([{ email, token }]);
if (error) {
return NextResponse.json({ error: "Database error" }, { status: 500 });
}
await sendOptInEmail(email, token);
return NextResponse.json({ data: "Success" }, { status: 200 });
}
};
return await handleEmailSubmission(email);
}
First, we imported NextRequest
and NextResponse
to handle requests and responses in our API route. Then, we imported the arcjet
client from the lib
folder.
We also imported sendOptInEmail
for sending verification emails, supabase
for database interaction, and uuid
for generating unique tokens.
Next, we handled incoming GET
requests to check if the request was suspicious. If it was denied, we logged this action and returned a 403
response.
Then, we checked if the request came from a bot. If the request was denied, we logged this and returned a 403
response.
After that, we handled incoming POST
requests. We first parsed the request body to extract the received email address.
We then checked rate limits. If the request was denied due to too many attempts, we extracted the resetTime
and calculated the remaining time before the user could retry.
Next, we validated the email address. If the email was invalid, disposable, or lacked MX records, we checked the specific error type emailTypes
and returned a 400
response.
Then, we created an async function checkEmailExists
to verify if the email was already in our subscribers
database. If an error occurred, we logged it and returned false
.
Finally, we created a function handleEmailSubmission
to handle the confirmation emails. We generated a unique token with UUID, inserted it into the pending_subscriptions
table with Supabase, and sent an opt-in email using Nodemailer.
Creating the Confirmation API route
This route will handle email confirmation by validating the token provided in the query parameters of the verification URL and will forward the user to the confirmation screen.
Navigate to the lib
folder in your NextJS project and create a new confirm
folder. Next, create a route.tsx
file inside it.
Include the following code in route.tsx
:
import { NextRequest, NextResponse } from "next/server";
import supabase from "@/lib/supabase";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
console.log("Invalid token");
return NextResponse.json({ error: "Invalid token" }, { status: 400 });
}
const { data: record, error } = await supabase
.from("pending_subscriptions")
.select("*")
.eq("token", token)
.single();
if (error || !record) {
console.log("Token not found");
return NextResponse.json({ error: "Token not found" }, { status: 400 });
}
await supabase.from("subscribers").insert([{ email: record.email }]);
await supabase.from("pending_subscriptions").delete().eq("token", token);
return NextResponse.redirect(`${process.env.BASE_URL}/?approved=true`);
}
First, we imported NextRequest
and NextResponse
from "next/server"
. We also imported the supabase
client to work with the database.
Next, we exported an async function for GET
requests. We retrieved the token
from the URL query parameters using req.nextUrl.searchParams
. If the token was not present in the request, we returned a 400
response.
After that, we validated the token by querying the pending_subscriptions
table. If an error occurred or the record was not found, we returned a 400
response. If the record was found, we marked the email as confirmed.
We then deleted the token record from the pending_subscriptions
table to clean up the database. Note that this is optional, and you could keep those records if you need to access them to see when the confirmation links were sent.
Finally, we redirected the user to the confirmation view with a approved=true
query parameter. This confirmed the verification to the user.
Building the User Interface
In our previous sections, we created the backend for the application or the logic of how the app will handle the user requests and send the responses.
Now we will create a frontend or the the visual part of the application.
We will start by resetting the default styling.
Open the globals.css
file in your app
folder and remove all of the styling rules except for the Tailwind imports, so the file now looks like this:
@tailwind base;
@tailwind components;
@tailwind utilities;
Next, open the page.tsx
file in your app
folder and include the following code:
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
export default function Home() {
const [isLoading, setIsLoading] = useState(false);
const [inputValue, setInputValue] = useState("");
const [isEmailSubmitted, setIsEmailSubmitted] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [isErrorState, setIsErrorState] = useState(false);
const [infoMessage, setInfoMessage] = useState(
"Sent out weekly on Sundays. Always free."
);
const searchParams = useSearchParams();
useEffect(() => {
const queryParam = searchParams.get("approved");
if (queryParam === "true") {
setIsSubscribed(true);
setInfoMessage("See you soon in the email! Best for now!");
}
setIsLoading(false);
}, [searchParams]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
const response = await fetch("/api/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: inputValue }),
});
const result = await response.json();
if (result.data === "Success") {
setIsEmailSubmitted(true);
setInfoMessage("The link should be in your email shortly.");
setIsLoading(false);
} else {
setIsErrorState(true);
setInfoMessage(result.error || "An error occurred.");
setIsLoading(false);
}
} catch (error) {
setInfoMessage("An unexpected error occurred.");
setIsLoading(false);
}
};
return (
<main className="flex items-center justify-center min-h-screen ">
{isLoading ? (
<Image
className="w-auto"
src={"/loading.gif"}
alt="loading"
width={80}
height={80}
></Image>
) : (
<div className="flex flex-col items-center justify-center text-center max-w-[650px] min-h-[350px] p-10 space-y-6">
<Image
className="w-auto"
src={
isSubscribed
? "/subscribed.png"
: isEmailSubmitted
? "/approval.png"
: isErrorState
? "/error.png"
: "/unsubscribed.png"
}
alt="logo"
width={100}
height={100}
/>
<h1 className="text-2xl font-bold">
{isSubscribed
? "You are all set! Thank you!"
: isEmailSubmitted
? "Verification email sent!"
: isErrorState
? "Subscription failed!"
: "Subscribe to My Newsletter"}
</h1>
<p className="text-lg text-gray-700">
{isSubscribed
? "The verification was successful and I'm sure this is a great beginning for something special."
: isEmailSubmitted
? "Double opt-in ensures that only valid and genuinely interested subscribers are added to my mailing list."
: isErrorState
? "Unfortunately, your email was not added to the newsletter list due to reason in the warning message."
: "Join the subscribers list to get the latest news, updates, and special offers delivered directly to your inbox."}
</p>
{!isEmailSubmitted && !isSubscribed && !isErrorState ? (
<form
className="flex flex-col sm:flex-row w-full px-4 space-y-2 sm:space-y-0 sm:space-x-2"
onSubmit={handleSubmit}
>
<input
type="text"
placeholder="Enter your email"
className="flex-grow border border-gray-300 p-2 sm:w-8/12 rounded focus:outline-none"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
required
/>
<button
className="w-full sm:w-4/12 bg-teal-500 text-white p-2 hover:bg-teal-600 rounded"
type="submit"
>
Subscribe
</button>
</form>
) : (
<button
className="w-full bg-teal-500 text-white p-2 hover:bg-teal-600 rounded"
type="button"
onClick={() => {
setIsSubscribed(false);
setIsEmailSubmitted(false);
setIsErrorState(false);
setInfoMessage("Sent out weekly on Sundays. Always free.");
}}
>
{isErrorState ? "Let me try again" : "Back to start"}
</button>
)}
{!isErrorState ? (
<p className="text-md text-gray-700">{infoMessage}</p>
) : (
<p className="text-md text-red-500">{infoMessage}</p>
)}
</div>
)}
</main>
);
}
First, we declared use client
at the top of the file, indicating that this is a client-side NextJS component.
Next, we brought in useEffect
and useState
to manage states and side effects. We also imported useSearchParams
to access URL query parameters and the Image
component to handle images.
Then we defined the Home
component, which was exported as the default export of the module. Inside it, we declared several state variables using the useState
hook:
isLoading
to track the loading state;inputValue
to store the user's email input;isEmailSubmitted
to indicate if the email has been successfully submitted;isSubscribed
to track if the user has successfully subscribed;isErrorState
to indicate if there was an error during the process;infoMessage
to store a message displayed to the user, initialized with a default message.
Next, we utilized the useEffect
hook to handle side effects when the component renders to see if the request is the initial request.
We then defined an async handleSubmit
function, which handles the form submission when the user tries to subscribe.
In the return statement, we defined the UI for the component using JSX.
The email input
form consists of an input field and a submit
button and is shown only if the user hasn't yet submitted their email or has not encountered an error.
If the user has already subscribed, submitted their email, or encountered an error, we rendered a "Back to start" or "Let me try again" button that resets the relevant states.
Finally, we displayed the infoMessage
text based on whether it was informational or indicated an error.
Testing attacks from the terminal
Now let's test the security of our application.
First, make sure your developer server is still running. If it is not, run npm run dev
to start it again.
We will first simulate the attacks from the terminal via curl
. We will not see any warnings on the app UI, but they will still be logged in our console.
1. Attack protection
Open a new terminal, besides the one running the developer server.
Execute the command curl -v -H "x-arcjet-suspicious: true" http://localhost:3000/api/submit
.
Check your terminal response. You should get a warning message indicating suspicious activity has been detected.
Now switch over to your Arcjet project dashboard. The log will be displayed, indicating which protection reason blocked the request.
Shield detects suspicious behavior, such as SQL injection and cross-site scripting attacks.
2. Bot detection
Now let's see if our application is safe from bots. Again we will use a curl
command to simulate the attack.
Execute the command curl -v http://localhost:3000/api/submit
.
Check your terminal and you should see a message that the request has been blocked and Arcject has identified a bot activity.
Now, switch to the Arcject dashboard. Again, you should see the blocked request and the reason for it.
Remember we set the LIVE
protection mode for the Arcjet instances in the lib
folder meaning that this would block the requests.
You can always enable DRY_RUN
mode so the results would be only logged.
Testing errors from the UI
Now let's use the user interface to test our app.
Click on the developer server link in your primary terminal window or open your web browser and navigate to http://localhost:3000 to open the app.
1. Email protection
First, letâs test our app by trying to register with an invalid email address.
For example, info@website
is not a valid email address format since it is missing the domain to ensure proper routing and delivery of messages.
Next, weâll attempt to use a disposable email address.
For example, user123@mytemp.email
is a disposable email, that would usually come from a provider that offers temporary, short-term email addresses.
Finally, weâll input an email address that appears valid but lacks MX records.
For example, support@missingmx.com
is valid in format and domain but cannot receive emails due to the absence of MX records.
In each of the above cases, the registration attempts were denied, and error messages were properly displayed, preventing the submission.
Behind the scenes, Arcjet blocked invalid, disposable, or non-functional emails before they could be processed, ensuring that only reliable emails were accepted.
2. Rate limiting
Now, letâs test the rate-limiting feature by attempting to submit an email multiple times in a short time.
We set the rate limit to allow only 3 requests within a 2-minute window. We should hit the rate limit if we submit more than three times rapidly.
As expected, additional requests were blocked once the limit was reached.
The UI responded by switching to an error view, displaying the warning message indicating that further submissions are temporarily restricted.
Letâs wait for a minute and try submitting the email again.
You'll notice that the UI continuously updates the warning message with the remaining time for the rate-limiting block, keeping the user informed.
This real-time feedback ensures a smoother user experience while protecting your server from excessive requests.
3. Checking double emails
Next, we will check against the existing records in the table since you do not want repeated emails in your subscriber database.
For this test, I have purposely entered an already existing email in the subscribers
database, so we could get an error when trying to submit it.
Behind the scenes, a POST
request was sent to the server when the form was submitted. The submit
API route checked if the email already existed.
Since the email was found in the database, the server returned an error, and the client-side UI responded by displaying a warning message indicating the duplication.
This feature helps ensure that your mailing list remains free of redundant entries.
Testing successful double opt-in
Finally, let's walk through the complete process of successfully subscribing to the newsletter.
First, enter a valid email address and submit it.
Arcjet passed all security checks and the app transitioned to the pending screen. Behind the scenes, the email was added to the pending_subscriptions
table in Supabase.
Next, Nodemailer sent a confirmation email to the provided address. Wait a few seconds for the confirmation email to arrive in your inbox, and click on the confirmation link.
This initiated Supabase to remove the user data from pending_subscriptions
table and add the email to the main subscribers
table.
Finally, the user was redirected to the confirmation screen indicating the successful subscription.
Congratulations, you have created a fully functional newsletter subscription application!
Conclusion
By using NextJS, Supabase, Nodemailer, and Arcjet, you can build a robust solution that processes emails effectively and ensures a high level of security.
Arcjet plays a crucial role in this setup by providing useful protection mechanisms such as bot detection, rate limiting, and email validation.
Supabase and Nodemailer complemented this by offering seamless database management and reliable email delivery.
I hope the article was helpful and you learned how to build a secure newsletter subscription system for your future projects!
Writing has always been my passion and it gives me pleasure to help and inspire people. If you have any questions, feel free to reach out!
Make sure to receive the best resources, tools, productivity tips, and career growth tips I discover by subscribing to my newsletter!