Introduction
Building a secure, scalable, and fully-featured newsletter subscription system from scratch can be a complex task.
In this step-by-step guide, you'll learn how to build a complete, production-ready newsletter platform using three powerful tools:
- Strapi — a flexible open-source headless CMS. We will use it to manage subscribers and newsletter content.
- Brevo (formerly Sendinblue) — a robust email delivery and automation service.
- Next.js — a modern React framework for building performant, user-friendly frontend applications.
Tutorial Goals
At the end of this tutorial, you should be able to do the following:
- Add new subscribers to the newsletter using a sign-up form.
- Manage subscribers within the Strapi Admin Dashboard.
- Create newsletters inside the Strapi Admin Dashboard.
- Send newsletters to a mailing list of subscribers.
- Implement Strapi Middleware for Logging, Subscription Validation, Rate Limiting, and more.
This guide covers everything from setup to production-ready security. See the demo below:
Let's get started!
Prerequisites
In order to proceed with this tutorial, ensure you have the following.
Set up Strapi
Open your working directory in your terminal, and create a Strapi app named newsletter
using the following command:
1npx create-strapi-app@latest newsletter
Answer the prompts as follows:
1Ok to proceed? (y) y
2? Please log in or sign up. Skip
3? Do you want to use the default database (sqlite) ? Yes
4? Start with an example structure & data? No
5? Start with Typescript? No
6? Install dependencies with npm? Yes
7? Initialize a git repository? No
Create an admin user for your Strapi app inside the newsletter
folder:
1cd newsletter
1npm run strapi admin:create-user -- --firstname=Kai --lastname=Doe --email=chef@strapi.io --password=Gourmet1234
How to Send Emails With Strapi and Brevo
Generate Strapi API for Email Delivery
Create an API and name it email-news
. You will use it to send emails from the Strapi backend server.
1npm run strapi generate
Choose api - Generate a basic API
. Name it email-news
. Choose no
for is this api for a plugin?
.
The following files will be generated:
/api/email-news/routes/email-news.js
/api/email-news/controllers/email-news.js
/api/email-news/services/email-news.js
Configure Brevo
Sign in to your Brevo account, and generate a new API key in the API keys page.
Add your Brevo API key to your environment variables by updating the .env
file.
1BREVO_API_KEY=Your-newly-generated-Brevo-API-key
Install the Brevo SDK.
1npm install sib-api-v3-sdk
Create a Strapi Custom Email Service
Create a custom email service by updating newsletter/src/api/email-news/services/email-news.js
1// Path: newsletter/src/api/email-news/services/email-news.js
2
3"use strict";
4
5const SibApiV3Sdk = require("sib-api-v3-sdk");
6
7module.exports = {
8 async sendEmail({ to, subject, htmlContent }) {
9 try {
10 // Initialize Brevo API client
11 const defaultClient = SibApiV3Sdk.ApiClient.instance;
12 const apiKey = defaultClient.authentications["api-key"];
13 apiKey.apiKey = process.env.BREVO_API_KEY;
14
15 // Configure the email sender and recipient
16 const apiInstance = new SibApiV3Sdk.TransactionalEmailsApi();
17 const sendSmtpEmail = new SibApiV3Sdk.SendSmtpEmail();
18
19 sendSmtpEmail.sender = { name: "Your Business", email: "your-email@example.com" };
20 sendSmtpEmail.to = [{ email: to }];
21 sendSmtpEmail.subject = subject;
22 sendSmtpEmail.htmlContent = htmlContent;
23
24 // Send the email
25 const response = await apiInstance.sendTransacEmail(sendSmtpEmail);
26 return response;
27 } catch (error) {
28 console.error("Error sending email:", error);
29 throw new Error("Failed to send email");
30 }
31 },
32};
This initializes the Brevo API client, configures the email sender and recipient, and then sends the email.
Replace your "your-email@example.com" with your Brevo account email.
Create a Strapi API controller to Send Emails
Create an API controller named send
in newsletter/src/api/email-news/controllers/email-news.js
.
1// Path: newsletter/src/api/email-news/controllers/email-news.js
2
3"use strict";
4
5module.exports = {
6 async send(ctx) {
7 try {
8
9 const { to, subject, htmlContent } = ctx.request.body;
10
11 if (!to || !subject || !htmlContent) {
12 return ctx.badRequest("Missing required fields: to, subject, htmlContent");
13 }
14
15 // Access service using `strapi.service()`
16 await strapi.service("api::email-news.email-news").sendEmail({ to, subject, htmlContent });
17
18 ctx.send({ message: "Email sent successfully" });
19 } catch (error) {
20 console.error("Email error:", error);
21 ctx.send({ error: "Failed to send email", details: error.message });
22 }
23 },
24};
This controller checks for the required email fields, then uses the sendEmail
service created earlier to send the email.
Create a Strapi API Route for Sending Email
Create an API route at the path /send-email
in newsletter/src/api/email-news/routes/email-news.js
.
1// Path: newsletter/src/api/email-news/routes/email-news.js
2
3module.exports = {
4 routes: [
5 {
6 method: "POST",
7 path: "/send-email",
8 handler: "email-news.send",
9 config: {
10 auth: false, // Set to true if authentication is required
11 },
12 },
13 ],
14};
You have added a route /send-email
for the send
controller.
Test Strapi Email API
Test the email API by sending an email to a valid email address.
Run Strapi server.
1npm run develop
Run the following command in a new terminal session:
1curl -X POST http://localhost:1337/api/send-email \
2 -H "Content-Type: application/json" \
3 -d '{
4 "to": "johndoe@email.com",
5 "subject": "Test 1: Welcome!",
6 "htmlContent": "<p>Thank you for signing up!</p>"
7 }'
NOTE: Use an email address whose inbox you can access.
Verify email delivery. You will get this response:
1{ "message": "Email sent successfully" }
Check your mailbox to confirm delivery.
How to Send Email to a Single Subscriber
Now that we know that our Strapi email API is working and can send emails via Brevo API we can test sending emails to a collection created in the Strapi admin dashboard.
Create a Subscriber Collection
Visit your Strapi Admin dashboard in your browser: http://localhost:1337/admin
.
Create a new collection named Subscriber. Give it two fields:
- A text field called
name
. - An email field called
email
.
Add an entry to your collection using the Content Manager in the Strapi Admin Dashboard. Use an email address whose inbox you can access.
Enable Public Access to Subscriber Collection
Enable public read access(find
and findOne
) to the Subscriber collection.
Click Settings, then Users & Permissions Plugin, then Roles, and then Public.
Create Strapi API Controller to Send Email To a Subscriber
The objective is to fetch an entry from the Subscriber collection using the documentId
of the entry. Use the email
and name
fields to construct and send an email to the user.
Add a new controller, sendToSubscriber
, in newsletter/src/api/email-news/controllers/email-news.js
below the send
controller created earlier.
1// Path: newsletter/src/api/email-news/controllers/email-news.js
2
3"use strict";
4
5module.exports = {
6 //... Previous code
7
8 async sendToSubscriber(ctx) {
9 try {
10 const { id, subject, htmlContent } = ctx.request.body;
11 // id in this case is documentId
12
13 if (!id || !subject || !htmlContent) {
14 return ctx.badRequest("Missing required fields: id, subject, htmlContent");
15 }
16
17 //Fetch subscriber from database - Using Document Service API
18 const subscriber = await strapi.documents("api::subscriber.subscriber").findOne({
19 documentId: id,
20 fields: ["name", "email"],
21 });
22
23 if (!subscriber) {
24 return ctx.notFound("Subscriber not found");
25 }
26
27 const { email, name } = subscriber;
28
29 // Send email
30 await strapi.service("api::email-news.email-news").sendEmail({
31 to: email,
32 subject: subject.replace("{name}", name),
33 htmlContent: htmlContent.replace("{name}", name), // Replace {name} with subscriber's name
34 });
35
36 ctx.send({ message: `Email sent successfully to ${email}` });
37 } catch (error) {
38 console.error("Email error:", error);
39 ctx.send({ error: "Failed to send email", details: error.message });
40 }
41 },
42};
The sendToSubscriber
controller fetches the subscriber entry from the collection using a documentId
, retrieves the corresponding name
and email
, then sends an email using the sendEmail
service.
Add a new API Route for Sending to a Single Subscriber
Update newsletter/src/api/email-news/routes/email-news.js
.
1// Path:newsletter/src/api/email-news/routes/email-news.js
2
3module.exports = {
4 routes: [
5 //... Previous Code
6 {
7 method: "POST",
8 path: "/send-email-to-subscriber",
9 handler: "email-news.sendToSubscriber",
10 config: {
11 auth: false,
12 },
13 },
14 ],
15};
You have added a new route /send-email-to-subscriber
for the sendToSubscriber
controller.
Test API for Sending Email to a Single Subscriber
First, retrieve the documentId
for the Subscriber entry you created earlier.
1curl http://localhost:1337/api/subscribers?fields%5B0%5D=documentId
NOTE:
This query uses field selection. To learn more, check out Population and Filtering: Field Selection in the Strapi docs.
The result will look something like this:
1{
2 "data": [
3 {
4 "id": 3,
5 "documentId": "ud8fkwy24ga5xm7iduc5wjyk"
6 }
7 ],
8}
Copy the documentId
and use it to test the API route localhost:1337/api/send-email-to-subscriber
using the following command:
1curl -X POST http://localhost:1337/api/send-email-to-subscriber \
2 -H "Content-Type: application/json" \
3 -d '{
4 "id": "ud8fkwy24ga5xm7iduc5wjyk",
5 "subject": "Test 2: Hello, {name}!",
6 "htmlContent": "<p>Dear {name}, thank you for subscribing!</p>"
7 }'
Check your mailbox to confirm receipt of the email.
How to Send Email to All Subscribers
Add Multiple Entries to The Subscriber Collection
Add new entries to your collection using the Content Manager in the Strapi Admin Dashboard. Use email addresses whose inboxes you can access.
Your Subscriber collection should now have more than one entry.
Add a New API Controller for Sending Email to All Subscribers
Add a new controller, sendToAllSubscribers
, in newsletter/src/api/email-news/controllers/email-news.js
below the sendToSubscriber
controller.
1// Path: newsletter/src/api/email-news/controllers/email-news.js
2
3"use strict";
4
5module.exports = {
6 //... Previous code
7
8 async sendToAllSubscribers(ctx) {
9 try {
10 const { subject, htmlContent } = ctx.request.body;
11
12 if (!subject || !htmlContent) {
13 return ctx.badRequest("Missing required fields: subject, htmlContent");
14 }
15
16 // Fetch all subscribers - Using Document Service API
17
18 const subscribers = await strapi.documents("api::subscriber.subscriber").findMany({
19 fields: ["name", "email"],
20 });
21
22 if (subscribers.length === 0) {
23 return ctx.notFound("No subscribers found");
24 }
25
26 // Send emails to all subscribers
27 for (const subscriber of subscribers) {
28 await strapi.service("api::email-news.email-news").sendEmail({
29 to: subscriber.email,
30 subject: subject.replace("{name}", subscriber.name),
31 htmlContent: htmlContent.replace("{name}", subscriber.name),
32 });
33 }
34
35 ctx.send({ message: `Emails sent to ${subscribers.length} subscribers` });
36 } catch (error) {
37 console.error("Email error:", error);
38 ctx.send({ error: "Failed to send emails", details: error.message });
39 }
40 }
41};
The sendToAllSubscribers
controller fetches all subscriber entries from the Subscriber collection, retrieves the corresponding name
and email
for each Subscriber, then sends an email using the sendEmail
service.
Add New API Route for Emailing All Subscribers
Add a new route /send-email-to-all
for the sendToAllSubscribers
controller in newsletter/src/api/email-news/routes/email-news.js
.
1// Path: newsletter/src/api/email-news/routes/email-news.js
2
3module.exports = {
4 routes: [
5 //... Previous Code
6 {
7 method: "POST",
8 path: "/send-email-to-all",
9 handler: "email-news.sendToAllSubscribers",
10 config: { auth: false },
11 }
12 ],
13};
Test API for Sending to All Subscribers
Restart the Strapi server, then test the API:
1curl -X POST http://localhost:1337/api/send-email-to-all \
2 -H "Content-Type: application/json" \
3 -d '{
4 "subject": "Test 3: All Hello, {name}!",
5 "htmlContent": "<p>Dear {name}, thank you for subscribing!</p>"
6 }'
Check the mailboxes of the emails in the Subscriber collection to confirm receipt.
Sending Newsletter to Subscribers
Create Newsletter collection
Open your Strapi Admin and create a new collection called Newsletter. Add two fields:
- A text field (Short text) called
title
. - A text field (Long text) called
content
.
Add an entry to your Newsletter collection using the Content Manager.
Enable Public Access for Newsletter Collection API
Enable public read access (find
and findOne
) for the Newsletter collection.
Click Settings, then Users & Permissions Plugin, then Roles, and then Public.
Create a Controller to Send Newsletters
Create a new controller called sendNewsletter
inside src/api/newsletter/controllers/newsletter.js
:
1// Path: src/api/newsletter/controllers/newsletter.js
2
3"use strict";
4
5const { createCoreController } = require('@strapi/strapi').factories;
6
7module.exports = createCoreController('api::newsletter.newsletter', ({ strapi }) => ({
8 async sendNewsletter(ctx) {
9 try {
10 const { id } = ctx.request.body;
11 // id in this case is documentId
12
13 if (!id) {
14 return ctx.badRequest("Missing required field: id");
15 }
16
17 // Fetch the newsletter from the collection - Using Document Service API
18
19 const newsletter = await strapi.documents("api::newsletter.newsletter").findOne({
20 documentId: id,
21 fields: ["title", "content"],
22 });
23
24 if (!newsletter) {
25 return ctx.notFound("Newsletter not found");
26 }
27
28 const { title, content } = newsletter;
29
30 // Fetch all subscribers - Using Document Service API
31
32 const subscribers = await strapi.documents("api::subscriber.subscriber").findMany({
33 fields: ["name", "email"],
34 });
35
36 if (!subscribers || subscribers.length === 0) {
37 return ctx.notFound("No subscribers found");
38 }
39
40 // Send the newsletter to all subscribers
41 for (const subscriber of subscribers) {
42 const personalizedContent = content.replace("{name}", subscriber.name);
43
44 await strapi.service("api::email-news.email-news").sendEmail({
45 to: subscriber.email,
46 subject: title, // Use the newsletter title as the email subject
47 htmlContent: `<p>Dear ${subscriber.name},</p><p>${personalizedContent}</p>`,
48 });
49 }
50
51 ctx.send({ message: `Newsletter sent to ${subscribers.length} subscribers` });
52 } catch (error) {
53 console.error("Email error:", error);
54 ctx.send({ error: "Failed to send newsletter", details: error.message });
55 }
56 },
57}));
This sendNewsletter
controller fetches a newsletter entry from the Newsletter collection, retrieves all subscribers from the Subscriber collection, and then sends an email containing the newsletter to each subscriber using the sendEmail
service.
Create a Custom Route to Send Newsletters
Create a send-newsletter
route to be handled by the sendNewsletter
controller in a new file called src/api/newsletter/routes/custom-routes.js
:
1// Path: src/api/newsletter/routes/custom-routes.js
2
3module.exports = {
4 routes: [
5 {
6 method: "POST",
7 path: "/send-newsletter",
8 handler: "newsletter.sendNewsletter",
9 config: {
10 auth: false, // Set to true if authentication is required
11 },
12 },
13 ],
14};
Test Newsletter API
First, retrieve the documentId
for the Newsletter entry you created earlier.
1curl http://localhost:1337/api/newsletters?fields%5B0%5D=documentId
The result will look something like this:
1{
2 "data": [
3 {
4 "id": 1,
5 "documentId": "rxaoulexwvykziyuz25bqi8y"
6 }
7 ],
8}
Copy the documentId
and use it to test the API route, localhost:1337/api/send-newsletter
using the following command:
1curl -X POST http://localhost:1337/api/send-newsletter \
2 -H "Content-Type: application/json" \
3 -d '{
4 "id": "rxaoulexwvykziyuz25bqi8y"
5 }'
Check your mailbox.
How to Automatically Send Newsletter When Published
This step involves sending a newsletter to subscribers as soon as it is published, using lifecycle hooks.
Create Strapi Lifecycle Hook
Create a new file for lifecycle hooks in your Newsletter model:
Create src/api/newsletter/content-types/newsletter/lifecycles.js
:
1// Path: src/api/newsletter/content-types/newsletter/lifecycles.js
2
3module.exports = {
4 async afterCreate(event) {
5 const { result } = event;
6 if (result.publishedAt) {
7 await sendNewsletterToSubscribers(result.documentId);
8 }
9 },
10
11 async afterUpdate(event) {
12 const { result, params } = event;
13 // Check if the newsletter was just published
14 if (result.publishedAt && (!params.data.publishedAt || params.data.publishedAt === result.publishedAt)) {
15 await sendNewsletterToSubscribers(result.documentId);
16 }
17 },
18 };
19
20 async function sendNewsletterToSubscribers(id) {
21 try {
22 // Fetch the newsletter from the Newsletter collection
23
24 const newsletter = await strapi.documents("api::newsletter.newsletter").findOne({
25 documentId: id,
26 fields: ["title", "content"],
27 });
28
29 if (!newsletter) {
30 console.error("Newsletter not found");
31 return;
32 }
33
34 const { title, content } = newsletter;
35
36 // Fetch all subscribers - using Document Service API
37
38 const subscribers = await strapi.documents("api::subscriber.subscriber").findMany({
39 fields: ["name", "email"],
40 });
41
42 if (!subscribers || subscribers.length === 0) {
43 console.log("No subscribers found");
44 return;
45 }
46
47 // Send the newsletter to all subscribers
48 for (const subscriber of subscribers) {
49 const personalizedContent = content.replace("{name}", subscriber.name);
50
51 await strapi.service("api::email-news.email-news").sendEmail({
52 to: subscriber.email,
53 subject: `Test 5: ${title}`,
54 htmlContent: `<p>Dear ${subscriber.name},</p><p>${personalizedContent}</p>`,
55 });
56 }
57
58 console.log(`Newsletter sent to ${subscribers.length} subscribers`);
59 } catch (error) {
60 console.error("Email error:", error);
61 }
62 }
When triggered, this lifecycle hook fetches a newsletter from the Newsletter collection, retrieves all subscribers from the Subscriber collection, and sends an email containing the newsletter entry to all subscribers using the sendEmail
service.
The lifecycle hook will trigger in either of the following cases:
- A new newsletter is created with published status
- An existing newsletter is updated to published status
Test Automatic Sending of Newsletter
Publish a new newsletter in your Strapi Admin.
Check the mailboxes to confirm that the newsletter was delivered to the subscriber mailing list.
Create a Landing Page for Newsletter Subscription with Next.js
The next phase of this project involves configuring the Strapi backend Subscriber collection API to allow create and read operations.
You will also create a front-end landing page to allow users to subscribe using Next.js. One good thing about our project is that you can build a live Nextjs email marketing platform.
Enable Public Permissions (create
, findOne
, find
) for Subscriber API
Update the permissions for the Subscriber collection using the User & Permissions Plugin.
Click Settings, then Users & Permissions Plugin, then Roles, and then Public.
The allowed actions for the Subscriber collection API should now be create
, find
, and findOne
.
Test Subscriber Collection API
Test creating a new Subscriber using curl
.
1curl -X POST http://localhost:1337/api/subscribers \
2 -H "Content-Type: application/json" \
3 -d '{
4 "data": {
5 "name": "John",
6 "email": "john@doe.com"
7 }
8 }'
You should receive a response similar to the following:
1{
2 "data": {
3 "id": 10,
4 "documentId": "or399je8658l18ivi5p3u4rz",
5 "name": "John",
6 "email": "john@doe.com",
7 "createdAt": "2025-03-07T20:33:14.776Z",
8 "updatedAt": "2025-03-07T20:33:14.776Z",
9 "publishedAt": "2025-03-07T20:33:14.782Z"
10 },
11 "meta": {}
12}
Verify the updated list of subscribers.
1curl -X GET http://localhost:1337/api/subscribers
Create a Minimal Subscription Form (name, email) in Next.js
Next, let's introduce the Next.js frontend into this project.
Create a Next.js project
Create a directory named frontend
.
1mkdir frontend && cd frontend
Install react
, react-dom
, and next
as npm dependencies in the frontend
directory.
1npm install next@latest react@latest react-dom@latest
Open your package.json
file and add the following npm scripts
1{
2 "scripts": {
3 "dev": "next dev",
4 "build": "next build",
5 "start": "next start",
6 "lint": "next lint"
7 },
8 "dependencies": {
9 "next": "^15.2.1",
10 "react": "^19.0.0",
11 "react-dom": "^19.0.0"
12 }
13}
Create Layout and Home Page in Next.js
Create an app
folder, and then add a layout.tsx
and page.tsx
file.
1mkdir app && touch app/layout.tsx app/page.tsx
Create the root layout inside app/layout.tsx
:
1// Path: frontend/app/layout.tsx
2
3mport React from 'react';
4
5export default function RootLayout({
6 children,
7}: {
8 children: React.ReactNode
9}) {
10 return (
11 <html lang="en">
12 <body>{children}</body>
13 </html>
14 )
15}
Create the home page, app/page.tsx
:
1// Path: frontend/app/page.tsx
2
3import React from 'react';
4
5export default function Page() {
6 return (
7 <form action="#" method="post">
8 <label htmlFor="name">Name:</label>
9 <input type="text" id="name" name="name" required />
10
11 <label htmlFor="email">Email:</label>
12 <input type="email" id="email" name="email" required />
13
14 <button type="submit">Subscribe</button>
15 </form>
16 );
17}
Run the development server.
npm run dev
Visit http://localhost:3000 to view your site.
Soon, we will update the frontend with a more aesthetic design using Tailwind CSS.
Update the home page, app/page.tsx
Update the home page by modifying frontend/app/page.tsx
as follows:
1// Path: frontend/app/page.tsx
2
3"use client";
4
5import React from "react";
6import { useState } from "react";
7
8export default function Page() {
9 const [name, setName] = useState("");
10 const [email, setEmail] = useState("");
11 const [message, setMessage] = useState("");
12
13 const handleSubmit = async (e: React.FormEvent) => {
14 e.preventDefault();
15
16 try {
17 const response = await fetch("http://localhost:1337/api/subscribers", {
18 method: "POST",
19 headers: {
20 "Content-Type": "application/json",
21 },
22 body: JSON.stringify({
23 data: { name, email },
24 }),
25 });
26
27 if (!response.ok) {
28 throw new Error("Failed to subscribe");
29 }
30
31 setMessage("Subscription successful!");
32 setName("");
33 setEmail("");
34 } catch (error) {
35 setMessage("Subscription failed. Try again.");
36 }
37 };
38
39 return (
40 <div>
41 <form onSubmit={handleSubmit}>
42 <label htmlFor="name">Name:</label>
43 <input
44 type="text"
45 id="name"
46 name="name"
47 value={name}
48 onChange={(e) => setName(e.target.value)}
49 required
50 />
51
52 <label htmlFor="email">Email:</label>
53 <input
54 type="email"
55 id="email"
56 name="email"
57 value={email}
58 onChange={(e) => setEmail(e.target.value)}
59 required
60 />
61
62 <button type="submit">Subscribe</button>
63 </form>
64
65 {message && <p>{message}</p>}
66 </div>
67 );
68}
This code defines a React component for the subscription form. It allows users to enter their name and email address, and then submit the form to subscribe. The form data is sent to the Strapi backend (http://localhost:1337/api/subscribers
) via a POST request. If successful, a success message is displayed; otherwise, an error message is shown.
Test Newsletter Subscription Form
Launch your Next.js development server and test the newsletter subscription form by entering a name and email address.
Verify whether the Subscriber collection has been updated in your Strapi admin.
Create Strapi Middleware for Form Validation, Request Logging, Duplicate Emails, and Rate Limiting
Create custom middleware in Strapi to handle form submission validation, log requests, check for duplicate emails, and apply rate limiting to prevent spam.
1mkdir src/middlewares
Create a Strapi Middleware to Log Form Submissions
Create a new middleware file, frontend/src/middlewares/form-handler.js
, inside the Strapi backend folder:
1// Path: frontend/src/middlewares/form-handler.js
2module.exports = (config, { strapi }) => {
3 return async (ctx, next) => {
4 if (ctx.path === '/api/subscribers' && ctx.method === 'POST') {
5 strapi.log.info(`Name: ${ctx.request.body.data.name} Email: ${ctx.request.body.data.email}`);
6 }
7 await next();
8 };
9};
Register the middleware by updating newsletter/config/middlewares.js
to include your middleware:
1// Path: newsletter/config/middlewares.js
2
3module.exports = [
4 'strapi::logger',
5 'strapi::errors',
6 'strapi::security',
7 'strapi::cors',
8 'strapi::poweredBy',
9 'strapi::query',
10 'strapi::body',
11 'strapi::session',
12 'strapi::favicon',
13 'strapi::public',
14 'global::form-handler',
15];
Test the logger middleware by running the Strapi server.
Each time you submit the form, you should see the submissions logged in your Strapi backend terminal, similar to the following:
1Name: John Email: john@strapi.io
Create a Strapi Middleware for Rate Limiting
Update newsletter/config/middleware.js
to add rate limiting to your project to prevent abuse.
1// Path: newsletter/config/middlewares.js
2
3module.exports = [
4 'strapi::logger',
5 'strapi::errors',
6 {
7 name: 'strapi::security',
8 config: {
9 rateLimit: {
10 interval: 60 * 1000, // 1 minute
11 max: 100, // maximum 100 requests per minute
12 }
13 }
14 },
15 'strapi::cors',
16 'strapi::poweredBy',
17 'strapi::query',
18 'strapi::body',
19 'strapi::session',
20 'strapi::favicon',
21 'strapi::public',
22 'global::form-handler',
23];
What This Configuration Does:
- Time Window:
interval: 60 * 1000
sets a 1-minute window (60,000 milliseconds) - Request limit:
max: 100
allows up to 100 requests per IP address within that minute. - Scope: Applies globally to all API routes
For more information on how rate limiting works in Strapi, check out this tutorial: How to Set Up Rate Limiting in Strapi: Best Practices & Examples.
Create Strapi Middleware for Name Validation
Update newsletter/src/middlewares/form-handler.js
with the following code:
1// Path: newsletter/src/middlewares/form-handler.js
2module.exports = (config, { strapi }) => {
3 return async (ctx, next) => {
4 if (ctx.path === '/api/subscribers' && ctx.method === 'POST') {
5 const { name, email } = ctx.request.body.data;
6
7 // Initialize errors array
8 const errors = [];
9
10 //Validate name
11 if (!name) {
12 errors.push({ field: 'name', message: 'Name is required' });
13 } else if (name.length > 255) {
14 errors.push({ field: 'name', message: 'Name must be 255 characters or less'})
15 }
16
17 // Display errors
18 if (errors.length > 0) {
19 ctx.status = 400;
20 ctx.body = { errors };
21 return; // Stop execution and don't proceed to next middleware
22 }
23
24 // Log valid submission
25 strapi.log.info(`Valid Submission - Name: ${name} Email: ${email}`);
26 }
27 await next();
28 };
29};
We have updated form-handler.js
with validation checks for the submitted name. It verifies that a name is provided and limits it to a maximum of 255 characters.
All validation errors are collected into an array. If an error occurs, a 400 status code is returned with detailed error messages. Invalid data is prevented from proceeding to the next middleware.
To display the error messages in your Next.js frontend, update frontend/app/page.tsx
with the following code:
1// Path: app/page.tsx
2
3"use client";
4
5import React from "react";
6import { useState } from "react";
7
8export default function Page() {
9 const [name, setName] = useState("");
10 const [email, setEmail] = useState("");
11 const [message, setMessage] = useState("");
12
13 const handleSubmit = async (e: React.FormEvent) => {
14 e.preventDefault();
15
16 try {
17 const response = await fetch("http://localhost:1337/api/subscribers", {
18 method: "POST",
19 headers: {
20 "Content-Type": "application/json",
21 },
22 body: JSON.stringify({
23 data: { name, email },
24 }),
25 });
26
27 const data = await response.json();
28
29 if (!response.ok) {
30 if (data.errors) {
31 // Display validation error
32 setMessage(data.errors.map(err => `${err.field}: ${err.message}`).join(', '));
33 } else {
34 throw new Error("Failed to subscribe");
35 }
36 } else {
37 setMessage("Subscription successful!");
38 setName("");
39 setEmail("");
40 }
41 } catch (error) {
42 setMessage("Subscription failed. Try again.");
43 }
44 };
45
46 return (
47 <div>
48 <form onSubmit={handleSubmit}>
49 <label htmlFor="name">Name:</label>
50 <input
51 type="text"
52 id="name"
53 name="name"
54 value={name}
55 onChange={(e) => setName(e.target.value)}
56 required
57 />
58
59 <label htmlFor="email">Email:</label>
60 <input
61 type="email"
62 id="email"
63 name="email"
64 value={email}
65 onChange={(e) => setEmail(e.target.value)}
66 required
67 />
68
69 <button type="submit">Subscribe</button>
70 </form>
71
72 {message && <p>{message}</p>}
73 </div>
74 );
75}
This setup:
- Prevents invalid names from being saved in the Subscriber collection.
- Provides feedback to users about validation issues.
- Logs only valid submissions to the server logs.
Test the name validation middleware by sending a blank name or providing a name that exceeds 255 characters.
Use browser Developer Tools (Ctrl
+ Shift
+ I
) to make the form submissions.
Create Strapi Middleware for Email Validation
Update newsletter/src/middlewares/form-handler.js
with the following code:
1// Path: newsletter/src/middlewares/form-handler.js
2
3module.exports = (config, { strapi }) => {
4 return async (ctx, next) => {
5 if (ctx.path === '/api/subscribers' && ctx.method === 'POST') {
6 const { name, email } = ctx.request.body.data;
7
8 // Initialize errors array
9 const errors = [];
10
11 //Validate name
12 if (!name) {
13 errors.push({ field: 'name', message: 'Name is required' });
14 } else if (name.length > 255) {
15 errors.push({ field: 'name', message: 'Name must be 255 characters or less'})
16 }
17
18 // Validate email
19 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
20 if (!email) {
21 errors.push({ field: 'email', message: 'Email is required' });
22 } else if (!emailRegex.test(email)) {
23 errors.push({ field: 'email', message: 'Please provide a valid email address' });
24 }
25
26 // Display errors
27 if (errors.length > 0) {
28 ctx.status = 400;
29 ctx.body = { errors };
30 return; // Stop execution and don't proceed to next middleware
31 }
32
33 // Log valid submission
34 strapi.log.info(`Valid Submission - Name: ${name} Email: ${email}`);
35 }
36 await next();
37 };
38};
This setup:
- Checks if an email address is provided.
- Validates email format using a regex pattern
/^[^\s@]+@[^\s@]+\.[^\s@]+$/
.
Test the email validation middleware by submitting a blank email field or providing an invalid email address.
Use browser Developer Tools (Ctrl
+ Shift
+ I
) to make the form submissions.
Create Strapi Middleware to Check for Duplicate Email Addresses
In addition to email validation, you can prevent duplicate email entries by checking for existing email addresses.
Update newsletter/src/middlewares/form-handler.js
with the following code:
1// newsletter/src/middlewares/form-handler.js
2module.exports = (config, { strapi }) => {
3 return async (ctx, next) => {
4 if (ctx.path === '/api/subscribers' && ctx.method === 'POST') {
5 const { name, email } = ctx.request.body.data;
6
7 // Initialize errors array
8 const errors = [];
9
10 //Validate name
11 if (!name) {
12 errors.push({ field: 'name', message: 'Name is required' });
13 } else if (name.length > 255) {
14 errors.push({ field: 'name', message: 'Name must be 255 characters or less'})
15 }
16
17 // Validate email
18 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19 if (!email) {
20 errors.push({ field: 'email', message: 'Email is required' });
21 } else if (!emailRegex.test(email)) {
22 errors.push({ field: 'email', message: 'Please provide a valid email address' });
23 }
24
25 // Check for existing email to prevent duplicates
26 if (email && emailRegex.test(email)) {
27 try {
28 const existingSubscriber = await strapi.documents("api::subscriber.subscriber").findMany({
29 filters: { email: email },
30 });
31
32 if (existingSubscriber && existingSubscriber.length > 0) {
33 errors.push({ field: 'email', message: 'This email is already subscribed' });
34 }
35 } catch (error) {
36 strapi.log.error('Error checking for existing email:', error);
37 }
38 }
39
40 // Display errors
41 if (errors.length > 0) {
42 ctx.status = 400;
43 ctx.body = { errors };
44 return; // Stop execution and don't proceed to next middleware
45 }
46
47 // Log valid submission
48 strapi.log.info(`Valid Submission - Name: ${name} Email: ${email}`);
49 }
50 await next();
51 };
52};
Test the email duplication check by entering a duplicate email address in your newsletter subscription form.
Disable Public API in Strapi
Disable public access to your Newsletter and Subscriber collections in the Strapi project. This is to allow only authenticated users to use your app.
Click Settings, then Users & Permissions Plugin, then Roles, and then select Public.
For the Subscriber collection, remove all public access to the API.
For the Newsletter collection, remove all public access to the API.
Add Strapi Authentication using API Tokens
To secure your API endpoints while allowing controlled access from your Next.js front end, implement API token authentication.
Create an API Token in Strapi
Click Settings then API Tokens in your Strapi Admin Panel.
Click Create new API Token.
Configure the token as follows:
- Name:
Next.js Frontend
- Description:
Token for newsletter subscription form
- Token type:
Custom
- Duration: Set preferred duration
Under Permissions enable the create
option for the Subscriber collection.
Click Save, and copy the generated token (store it securely).
Add Environment variables in the Next.js Frontend
In your Next.js frontend project folder, create a .env.local
file and add your Strapi API token and Strapi server URL.
1STRAPI_API_TOKEN=your-generated-token-here
2STRAPI_URL=http://localhost:1337
Create a Server-side API route in Next.js
Create a Next.js API route named app/api/subscribe/route.ts
to allow users subscribe using their name and email.
1// Path: frontend/app/api/subscribe/route.ts
2
3import { NextRequest, NextResponse } from "next/server";
4
5export async function POST(req: NextRequest) {
6 const { name, email } = await req.json();
7
8 try {
9 const response = await fetch(`${process.env.STRAPI_URL}/api/subscribers`, {
10 method: "POST",
11 headers: {
12 "Content-Type": "application/json",
13 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
14 },
15 body: JSON.stringify({ data: { name, email } }),
16 });
17
18 const data = await response.json();
19
20 if (!response.ok) {
21 return NextResponse.json({ error: data }, { status: response.status });
22 }
23
24 return NextResponse.json({ success: true, data });
25 } catch (error) {
26 return NextResponse.json(
27 { message: "Internal server error", error },
28 { status: 500 }
29 );
30 }
31}
Update the landing page
Here's the complete updated code for app/page.tsx
that uses a secure API route (/api/subscribe
) to handle Strapi subscriptions while keeping the Bearer token and Strapi URL private:
1// Path: frontend/app/page.tsx
2
3"use client";
4
5import React, { useState } from "react",
6
7export default function Page() {
8const [name, setName] = useState("");
9const [email, setEmail] = useState("");
10const [message, setMessage] = useState("");
11
12const handleSubmit = async (e: React.FormEvent) => {
13 e.preventDefault();
14
15 try {
16 const response = await fetch("/api/subscribe", {
17 method: "POST",
18 headers: {
19 "Content-Type": "application/json",
20
21 },
22 body: JSON.stringify({ name, email }),
23 });
24
25 const data = await response.json();
26
27 if (!response.ok) {
28 if (data.errors) {
29 // Display validation error
30 setMessage(data.errors.map(err => `${err.field}: ${err.message}`).join(', '));
31 } else {
32 throw new Error("Failed to subscribe");
33 }
34 } else {
35 setMessage("Subscription successful!");
36 setName("");
37 setEmail("");
38 }
39 } catch (error) {
40 setMessage("Subscription failed. Try again.");
41 }
42 };
43
44 return (
45 <div>
46 <form onSubmit={handleSubmit}>
47 <label htmlFor="name">Name:</label>
48 <input
49 type="text"
50 id="name"
51 name="name"
52 value={name}
53 onChange={(e) => setName(e.target.value)}
54 required
55 />
56
57 <label htmlFor="email">Email:</label>
58 <input
59 type="email"
60 id="email"
61 name="email"
62 value={email}
63 onChange={(e) => setEmail(e.target.value)}
64 required
65 />
66
67 <button type="submit">Subscribe</button>
68 </form>
69
70 {message && <p>{message}</p>}
71 </div>
72 );
73}
Test Authentication
Submitting the form with a valid token should be successful.
Submitting the form without a token should fail with a 403 error.
Submitting the form with an invalid token should also fail with an error.
Remove Unused Code
Now that we have a complete newsletter system with lifecycle hooks, we can clean up redundant code and simplify our architecture.
Delete the following files from your Strapi project folder:
1rm src/api/email-news/routes/email-news.js
2rm src/api/newsletter/routes/custom-routes.js
This will remove the initial email-news
route and the custom newsletter
route, as they are no longer needed.
Add Design to Newsletter using Tailwind CSS
HTML Design
The final design of the landing page will look as follows:
Here's the link to the raw HTML for the complete design: Newsletter Landing Page HTML Design
Install Tailwind CSS and its peer dependencies
The design incorporates Tailwind CSS.
Install Tailwind CSS in your frontend project.
1npm install tailwindcss @tailwindcss/postcss postcss
Configure PostCSS Plugins
Create a postcss.config.mjs
file in the root of your project, and add the @tailwindcss/postcss
plugin to your PostCSS configuration.
1const config = {
2 plugins: {
3 "@tailwindcss/postcss": {},
4 },
5};
6
7export default config;
Add Tailwind directives to your CSS
Create your main CSS file named globals.css
inside your app
directory and add an @import
directive for Tailwind CSS:
1/* frontend/app/globals.css */
2@import "tailwindcss";
Import your global CSS file into your app/layout.tsx
file:
1// Path: frontend/app/layout.tsx
2import React from 'react';
3import './globals.css';
4
5export default function RootLayout({
6 children,
7}: {
8 children: React.ReactNode
9}) {
10 return (
11 <html lang="en">
12 <body className="bg-gray-50 font-sans text-gray-800">{children}</body>
13 </html>
14 )
15}
Update Landing Page With New Design
Update the app/page.tsx
file with the following code:
1"use client";
2
3import React, { useState } from "react";
4
5export default function Page() {
6 const [name, setName] = useState("");
7 const [email, setEmail] = useState("");
8 const [message, setMessage] = useState("");
9
10 const handleSubmit = async (e: React.FormEvent) => {
11 e.preventDefault();
12
13 try {
14 const response = await fetch("/api/subscribe", {
15 method: "POST",
16 headers: { "Content-Type": "application/json" },
17 body: JSON.stringify({ name, email }),
18 });
19
20 const data = await response.json();
21
22 if (!response.ok) {
23 if (data.errors) {
24 // Display validation error
25 setMessage(data.errors.map(err => `${err.field}: ${err.message}`).join(', '));
26 } else {
27 throw new Error("Failed to subscribe");
28 }
29 } else {
30 setMessage("✅ Subscription successful!");
31 setName("");
32 setEmail("");
33 }
34 } catch (error) {
35 setMessage("❌ Subscription failed. Please try again.")
36 }
37 };
38
39 return (
40 <section className="py-16 bg-white">
41 <div className="container mx-auto px-4">
42 <div className="max-w-2xl mx-auto text-center">
43 <h2 className="text-3xl font-bold mb-6">Join My Newsletter</h2>
44 <p className="text-gray-600 mb-8">Subscribe to receive new story alerts, writing insights, and exclusive content directly to your inbox.</p>
45
46 <form onSubmit={handleSubmit} className="space-y-4">
47 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
48 <div>
49 <label htmlFor="name" className="sr-only">Full Name</label>
50 <input
51 type="text"
52 id="name"
53 placeholder="Your Name"
54 value={name}
55 onChange={(e) => setName(e.target.value)}
56 required
57 className="w-full px-4 py-3 rounded-lg border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
58 />
59 </div>
60 <div>
61 <label htmlFor="email" className="sr-only">
62 Email Address
63 </label>
64 <input
65 type="email"
66 id="email"
67 placeholder="Email Address"
68 value={email}
69 onChange={(e) => setEmail(e.target.value)}
70 required
71 className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
72 />
73 </div>
74 <button
75 type="submit"
76 className="w-full md:w-auto px-6 py-3 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 transition duration-300"
77 >Subscribe Now</button>
78 </div>
79 </form>
80
81 {message && (
82 <p className="mt-6 text-sm text-gray-700">{message}</p>
83 )}
84 </div>
85 </div>
86 </section>
87 );
88}
View Updated Newsletter Landing Page Design
Stop and restart your Next.js development server to apply the changes. The landing page's sign-up form should be updated with the new design.
Demo: Newsletter Subscription System
GitHub Repo of Complete Code
The complete code for this project can be found here
Conclusion
By following this guide, you've successfully built a comprehensive newsletter subscription service leveraging the power of Strapi, Brevo, and Next.js.
You've learned to:
- Manage subscribers
- Create and automatically dispatch newsletters using lifecycle hooks
- Develop a frontend subscription form
Implement crucial backend validation and security measures like API token authentication.
This setup provides a solid foundation for engaging with your audience, offering a scalable and secure solution for your newsletter needs and even a Nextjs email marketing platform. You're now well-equipped to expand on this system or deploy it with confidence.
Mark Munyaka is a freelance web developer and writer who loves problem-solving and testing out web technologies. You can follow him on Twitter @McMunyaka