Strapi continues to be the most popular free, open-source, headless CMS, and, recently, it released v4. Built using Node.js with support for TypeScript, Strapi allows developers to perform CRUD operations using either REST or GraphQL APIs.
The best part of Strapi is that it allows users to customize its behavior, whether for the admin panel or the core business logic of your backend. You can modify its default controllers to include your own logic. For example, you might want to send an email when a new order is created.
In this tutorial, you’ll learn how to build a messaging app with Strapi on the backend and Next.js on the frontend. For this app, you’ll customize the default controllers to set up your own business logic.
What are Custom Controllers in Strapi?
Controllers stand for C in the MVC (Model-View-Controller) pattern. A controller is a class or file that contains methods to respond to the request made by the client to a route. Each route has a controller method associated with it, whose responsibility is to perform code operations and send back responses to the client.
In Strapi, whenever you create a new collection type, routes and controllers are created automatically in order to perform CRUD operations.
Depending on the complexity of your application, you might need to add your own logic to the controllers. For example, to send an email when a new order is created, you can add a piece of code to the controller associated with the /api/orders/create
route of the Order collection type and then call the original controller code. This is where Strapi shines, because it allows you to extend or replace the entire core logic for the controllers.
You’re going to customize the core controllers in Strapi by building a small but fun messaging application.
In this application, a user will be able to:
- Read the last five messages,
- Send a message,
- Edit a message, and
- Delete a message
Prerequisites
To follow along with this tutorial, you’ll need the following:
- Node.js—this tutorial uses Node v16.14.0
- npm—this tutorial uses npm v8.3.1
- Strapi—this tutorial uses Strapi v4.1.7
The entire source code is available in this GitHub repository.
Setting Up the Project
You’ll need a master directory that holds the code for both the frontend (Next.js) and backend (Strapi).
Open up your terminal, navigate to a path of your choice, and create a project directory by running the following command:
mkdir custom-controller-strapi
In the custom-controller-strapi
directory, you’ll install both Strapi and Svelte.js projects.
Setting Up Strapi v4
In your terminal, execute the following command to create the Strapi project:
npx create-strapi-app@latest backend --quickstart
This command will create a Strapi project with quickstart settings in the backend
directory.
Once the execution completes for the above command, your Strapi project will start on port 1337 and open up localhost:1337/admin/auth/register-admin in your browser.
Set up your administrative user:
Enter your details and click the Let’s Start button. You’ll be taken to the Strapi dashboard:
Creating Messages Collection Type
Under the Plugins header in the left sidebar, click the Content-Types Builder tab. Then click Create new collection type to create a new Strapi collection:
In the modal that appears, create a new collection type with Display Name - Message and click Continue:
Next, create the following four fields for your collection type:
- content: Text field with Long text type
- postedBy: Text field with Short text type
- timesUpdated: Number field with number format as Integer
- uid: UID field attached to the content field
Click the Finish button and save your collection type by clicking the Save button:
Your collection type is set up. Next you need to configure its permissions to access it via API routes.
Setting Up Permissions for Messages API
By default, Strapi API endpoints are not publicly accessible. For this app, you want to allow public access to it without any authentication. You need to update the permissions for the Public role.
First, click the Settings tab under the General header and then select Roles under the Users & Permissions Plugin. Click the Edit icon to the right of the Public Role:
Scroll down to find the Permissions tab and check all the allowed actions for the Message collection type. Click Save to save the updated permissions:
Next, you need to customize the controller to implement the business logic for your application.
Customizing Controllers in Strapi
There are three ways to customize the core controllers in Strapi:
- Create a custom controller
- Wrap a core action (leaves core logic in place)
- Replace a core action
To implement the logic for your messaging application, you need to configure three controller methods: find
, create
, and update
in the Messages collection type.
First, open message.js
file in src/api/message/controllers
and replace the existing code with the following:
"use strict";
/**
* message controller
*/
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController("api::message.message", ({ strapi }) => ({
async find(ctx) {
// todo
},
async create(ctx) {
// todo
},
async update(ctx) {
// todo
},
}));
Add the following code for the find
controller method:
async find(ctx) {
// 1
const entries = await strapi.entityService.findMany(
'api::message.message',
{
sort: { createdAt: 'DESC' },
limit: 5,
}
);
// 2
const sanitizedEntries = await this.sanitizeOutput(entries, ctx);
// 3
return this.transformResponse(sanitizedEntries);
},
In the above code:
- You use Strapi’s Service API to query the data (
strapi.entityService.findMany
) from the Message collection type. You use thesort
andlimit
filters to fetch the last fiveentries
from the Message collection type. - You sanitize the
entries
using Strapi’s built-insanitizeOutput
method. - You transform the
sanitizedEntries
using Strapi’s built-intransformResponse
method.
Basically, you replace an entire core action for the find
controller method and implement your own logic.
For the create
controller method, you need to add a uid
and timesUpdated
property to the request data. Add the following code for the create
controller method:
'use strict';
// 1
const { nanoid } = require('nanoid');
...
module.exports = createCoreController('api::message.message', ({ strapi }) => ({
...
async create(ctx) {
// 2
ctx.request.body.data = {
...ctx.request.body.data,
uid: nanoid(),
timesUpdated: 0,
};
// 3
const response = await super.create(ctx);
// 4
return response;
},
}));
In the above code:
- You import the
nanoid
package. - You add the
uid
andtimesUpdated
properties to the request data (ctx.request.body.data
). For generating theuid
, you use the nanoid npm package. - You call the core controller method (
super.create
) for creating a new entry in the Message collection type. - You return the
response
that contains the details related to the newly created entry.
You can install the nanoid
package by running the following command in your terminal:
npm i nanoid
Next, you need to customize the code for the update
controller method.
Whenever the update
controller method is called, you want to increment the timesUpdated
property by one. Also, you want to prevent the update of uid
and postedBy
properties, because you only want the user to update the content
property.
For this, add the following code for the update
controller method:
async update(ctx) {
// 1
const { id } = ctx.params;
// 2
const entry = await strapi.entityService.findOne(
'api::message.message',
id
);
// 3
delete ctx.request.body.data.uid;
delete ctx.request.body.data.postedBy;
// 4
ctx.request.body.data = {
...ctx.request.body.data,
timesUpdated: entry.timesUpdated + 1,
};
// 5
const response = await super.update(ctx);
// 6
return response;
},
In the above code:
- You get the
id
from thectx.params
by using object destructuring. - To update the
timesUpdated
property, you find the entry withid
in the Message collection type using Strapi’s Service API (strapi.entityService.findOne
) to get the existing value for thetimesUpdated
property. - You remove the
uid
andpostedBy
properties from the request body, since you don’t want the end user to update these properties. - You update the
timesUpdated
property by incrementing the existing value by one. - You call the core controller method (
super.update
) for updating the entry in the Message collection type. - You return the
response
that contains the details related to the updated entry.
Next, you need to connect to Strapi from your Next.js frontend application.
Setting Up Next.js Project
You need to build the Next.js frontend application and integrate it with the Strapi backend.
First, in the custom-controllers-strapi
directory, run the following command to create a Next.js project:
npx create-next-app@latest
On the terminal, when you are asked about the project’s name, set it to frontend
. It will install the npm dependencies.
After the installation is complete, navigate into the frontend
directory and start the Next.js development server by running the following commands in your terminal:
cd frontend
npm run dev
This will start the development server on port 3000 and take you to localhost:3000. The first view of the Next.js website will look like this:
Installing Required npm Packages
To make HTTP calls to the backend server, you can use the Axios npm package. To install it, run the following command in your terminal:
npm i axios
Don’t worry about styling the app right now—you’re concentrating on core functionality. You can learn to set up and customize Bootstrap in Next.js later in case you’d like to use Bootstrap as your UI framework.
Writing an HTTP Service
You need an HTTP service to connect with the Strapi API and perform CRUD operations.
First, create a services
directory in the frontend
directory. In the services
directory, create a MessagesApi.js
file and add the following code to it:
// 1
import { Axios } from "axios";
// 2
const axios = new Axios({
baseURL: "http://localhost:1337/api",
headers: {
"Content-Type": "application/json",
},
});
// 3
const MessagesAPIService = {
find: async () => {
const response = await axios.get("/messages");
return JSON.parse(response.data).data;
},
create: async ({ data }) => {
const response = await axios.post(
"/messages",
JSON.stringify({ data: data })
);
return JSON.parse(response.data).data;
},
update: async ({ id, data }) => {
const response = await axios.put(
`/messages/${id}`,
JSON.stringify({ data: data })
);
return JSON.parse(response.data).data;
},
delete: async ({ id }) => {
const response = await axios.delete(`/messages/${id}`);
return JSON.parse(response.data).data;
},
};
// 4
export { MessagesAPIService };
In the above code:
- You import the
axios
package. - You define an Axios instance (
axios
) and pass thebaseURL
andheaders
parameters. - You define the
MessagesAPIService
object and define the methods for the following:-
find
: this method returns a list of the last five messages. -
create
: this method is used to create a new message. -
update
: this method is used to edit an existing message. -
delete
: this method is used to delete a message.
-
- You export the
MessagesAPIService
object.
Creating UI Components
You’re going to create two components, one for rendering search results and another for rendering the search suggestions.
Create a components
directory in the frontend
directory. In the components
directory, create a Messagebox.js
file and add the following code to it:
// 1
import { useState } from "react";
// 2
const formatDate = (value) => {
if (!value) {
return "";
}
return new Date(value).toLocaleTimeString();
};
// 3
function Messagebox({ message, onEdit, onDelete }) {
// 4
const [isEditing, setIsEditing] = useState(false);
const [messageText, setMessageText] = useState(message.attributes.content);
// 5
const handleOnEdit = async (e) => {
e.preventDefault();
await onEdit({ id: message.id, message: messageText });
setIsEditing(false);
};
// 6
const handleOnDelete = async (e) => {
e.preventDefault();
await onDelete({ id: message.id });
};
// 7
return (
<div>
<div>
<p>{formatDate(message.attributes.createdAt)}</p>
<b>{message.attributes.postedBy}</b>
<p
contentEditable
onFocus={() => setIsEditing(true)}
onInput={(e) => setMessageText(e.target.innerText)}
>
{message.attributes.content}
</p>
</div>
<div>
{message.attributes.timesUpdated > 0 && (
<p>Edited {message.attributes.timesUpdated} times</p>
)}
{isEditing && (
<>
<button onClick={handleOnEdit}>Save</button>
<button onClick={() => setIsEditing(false)}>Cancel</button>
</>
)}
<button onClick={handleOnDelete}>Delete</button>
</div>
</div>
);
}
// 8
export default Messagebox;
In the above code:
- You import the
useState
hook from React. - You define the utility method (
formatDate
) for formatting the dates and times. - You define the
Messagebox
component that is used to render the data related to an individual message. This component takes in three props:-
message
—A message object returned from the API. -
onEdit
—Callback function to run when a message is edited. -
onDelete
—Callback function to run when a message is deleted.
-
- You define two state variables—
isEditing
andmessageText
. - You define the handler function (
handleOnEdit
) for theonEdit
event in which you call the callback function passed to theonEdit
prop. - You define the handler function (
handleOnDelete
) for theonDelete
event in which you call the callback function passed to theonDelete
prop. - You render the UI for the
Messagebox
component in which you use the<p>
tag as a content editable element to edit the message’s contents. - You export the
Messagebox
component.
Next, create a PublicMessagesPage.js
file in the components
directory and add the following code to it:
// 1
import React, { useState, useEffect } from "react";
import Messagebox from "./Messagebox";
import { MessagesAPIService } from "../services/MessagesApi";
// 2
function PublicMessagesPage() {
// 3
const [user, setUser] = useState("");
const [message, setMessage] = useState("");
const [messages, setMessages] = useState([]);
// 4
const fetchMessages = async () => {
const messages = await MessagesAPIService.find();
setMessages(messages);
};
// 5
useEffect(() => {
fetchMessages();
}, []);
// 6
const handleSendMessage = async (e) => {
e.preventDefault();
if (!user) {
alert("Please add your username");
return;
}
if (!message) {
alert("Please add a message");
return;
}
await MessagesAPIService.create({
data: {
postedBy: user,
content: message,
},
});
await fetchMessages();
setMessage("");
};
// 7
const handleEditMessage = async ({ id, message }) => {
if (!message) {
alert("Please add a message");
return;
}
await MessagesAPIService.update({
id: id,
data: {
content: message,
},
});
await fetchMessages();
};
// 8
const handleDeleteMessage = async ({ id }) => {
if (confirm("Are you sure you want to delete this message?")) {
await MessagesAPIService.delete({ id });
await fetchMessages();
}
};
// 9
return (
<div>
<div>
<h1>Random Talk</h1>
<p>Post your random thoughts that vanish</p>
</div>
<div>
<form onSubmit={(e) => handleSendMessage(e)}>
<input
type="text"
value={user}
onChange={(e) => setUser(e.target.value)}
required
/>
<div className="d-flex align-items-center overflow-hidden">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
/>
<button onClick={(e) => handleSendMessage(e)}>Send</button>
</div>
</form>
</div>
<div>
{messages.map((message) => (
<Messagebox
key={message.attributes.uid}
message={message}
onEdit={handleEditMessage}
onDelete={handleDeleteMessage}
/>
))}
</div>
</div>
);
}
// 10
export default PublicMessagesPage;
In the above code:
- You import the required npm packages and hooks from React.
- You define the
PublicMessagesPage
functional component. - You define the
state
variables for thePublicMessagesPage
component using theuseState
React hook:-
user
—Stores the current user’s name. -
message
—Stores the currently typed message. -
messages
—Stores all the messages fetched from the Strapi API.
-
- You define the
fetchMessages
method to get messages from the API and then update the state. - You call the
fetchMessage
method when the component is mounted by using theuseEffect
hook. - You define the
handleSendMessage
method in which you validate whether theuser
andmessage
state variables are not empty. Then you call thecreate
method from theMessageAPIService
and pass the requireddata
. Once the request is successful, you refresh the messages by calling thefetchMessages
method. - You define the
handleEditMessage
method in which you validate whether themessage
state variable is not empty. Then you call theupdate
method from theMessageAPIService
and pass the requiredid
anddata
. Once the request is successful, you refresh the messages by calling thefetchMessages
method. - You define the
handleDeleteMessage
method in which you call thedelete
method from theMessageAPIService
and pass the requiredid
. Once the request is successful, you refresh the messages by calling thefetchMessages
method. - You return the UI for the
PublicMessagesPage
component. - You export the
PublicMessagesPage
component.
Next, in the pages
directory, replace the existing code by adding the following code to the index.js
file:
// 1
import PublicMessagesPage from "../components/PublicMessagesPage";
// 2
function Home() {
return <PublicMessagesPage />;
}
// 3
export default Home;
In the above code:
- You import the
PublicMessagesPage
component. - You define the
Home
component for the localhost:3000 route in which you return thePublicMessagesPage
component. - You export the
Home
component as adefault
export.
Finally, save your progress and visit localhost:3000 to test your messaging app by performing different CRUD operations:
- Send a message:
- Edit a message:
- Delete a message:
Refreshing Messages
Currently, if someone adds, edits, or updates messages, you need to refresh the whole page to see that. To fetch new messages, you can poll the localhost:1337/api/messages endpoint every few seconds or use WebSockets. This tutorial will use the former approach.
To implement polling, create a utils
directory in the frontend
directory. In the utils
directory, create a hooks.js
file and add the following code to it:
import { useEffect, useRef } from "react";
function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
export { useInterval };
In the above code, you have defined a custom hook, useInterval
, that allows you to call the callback
after every delay
. This implementation is taken from software engineer Dan Abramov’s blog.
Next, update the PublicMessagesPage.js
file by adding the following code to it:
...
// 1
import { useInterval } from "../utils/hooks";
function PublicMessagesPage() {
...
// 2
useInterval(() => {
fetchMessages();
}, 10000);
...
}
In the above code:
- You import the
useInterval
hook. - You use the
useInterval
hook to call thefetchMessages
method every ten seconds (10000
ms).
To test the auto-refresh of messages, open localhost:3000 in at least two browser tabs or windows and try sending messages by setting different usernames. Here’s how the application works now:
That’s it. You have successfully implemented a messaging application using Strapi and Next.js.
Conclusion
In this tutorial, you learned to create a messaging application using Strapi for building the backend and Next.js for building the frontend UI. You also learned to customize the controllers in Strapi to implement your own business logic.
To check your work on this tutorial, go to this GitHub repository.