Using Custom Controllers to Power a Next.js App

Shada - May 11 '22 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Welcome screen

Enter your details and click the Let’s Start button. You’ll be taken to the Strapi dashboard:

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:

Create new collection type

In the modal that appears, create a new collection type with Display Name - Message and click Continue:

Collection configurations

Next, create the following four fields for your collection type:

  1. content: Text field with Long text type
  2. postedBy: Text field with Short text type
  3. timesUpdated: Number field with number format as Integer
  4. uid: UID field attached to the content field

Click the Finish button and save your collection type by clicking the Save button:

Save collection type

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:

Edit Roles

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:

Save 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
  },
}));
Enter fullscreen mode Exit fullscreen mode

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);
},
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You use Strapi’s Service API to query the data (strapi.entityService.findMany) from the Message collection type. You use the sort and limit filters to fetch the last five entries from the Message collection type.
  2. You sanitize the entries using Strapi’s built-in sanitizeOutput method.
  3. You transform the sanitizedEntries using Strapi’s built-in transformResponse 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;
  },
}));
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You import the nanoid package.
  2. You add the uid and timesUpdated properties to the request data (ctx.request.body.data). For generating the uid, you use the nanoid npm package.
  3. You call the core controller method (super.create) for creating a new entry in the Message collection type.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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;
},
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You get the id from the ctx.params by using object destructuring.
  2. To update the timesUpdated property, you find the entry with id in the Message collection type using Strapi’s Service API (strapi.entityService.findOne) to get the existing value for the timesUpdated property.
  3. You remove the uid and postedBy properties from the request body, since you don’t want the end user to update these properties.
  4. You update the timesUpdated property by incrementing the existing value by one.
  5. You call the core controller method (super.update) for updating the entry in the Message collection type.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Next.js welcome screen

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You import the axios package.
  2. You define an Axios instance (axios) and pass the baseURL and headers parameters.
  3. 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.
  4. 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;
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You import the useState hook from React.
  2. You define the utility method (formatDate) for formatting the dates and times.
  3. 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.
  4. You define two state variables—isEditing and messageText.
  5. You define the handler function (handleOnEdit) for the onEdit event in which you call the callback function passed to the onEdit prop.
  6. You define the handler function (handleOnDelete) for the onDelete event in which you call the callback function passed to the onDelete prop.
  7. 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.
  8. 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;
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You import the required npm packages and hooks from React.
  2. You define the PublicMessagesPage functional component.
  3. You define the state variables for the PublicMessagesPage component using the useState 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.
  4. You define the fetchMessages method to get messages from the API and then update the state.
  5. You call the fetchMessage method when the component is mounted by using the useEffect hook.
  6. You define the handleSendMessage method in which you validate whether the user and message state variables are not empty. Then you call the create method from the MessageAPIService and pass the required data. Once the request is successful, you refresh the messages by calling the fetchMessages method.
  7. You define the handleEditMessage method in which you validate whether the message state variable is not empty. Then you call the update method from the MessageAPIService and pass the required id and data. Once the request is successful, you refresh the messages by calling the fetchMessages method.
  8. You define the handleDeleteMessage method in which you call the delete method from the MessageAPIService and pass the required id. Once the request is successful, you refresh the messages by calling the fetchMessages method.
  9. You return the UI for the PublicMessagesPage component.
  10. 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;
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You import the PublicMessagesPage component.
  2. You define the Home component for the localhost:3000 route in which you return the PublicMessagesPage component.
  3. You export the Home component as a default export.

Finally, save your progress and visit localhost:3000 to test your messaging app by performing different CRUD operations:

  • Send a message:

Send a message

  • Edit a message:

Edit a message

  • Delete 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 };
Enter fullscreen mode Exit fullscreen mode

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);

  ...
}
Enter fullscreen mode Exit fullscreen mode

In the above code:

  1. You import the useInterval hook.
  2. You use the useInterval hook to call the fetchMessages 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:

Application auto-refresh

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .