Strapi works as a Headless CMS and provides a lot of functionality out of the box, allowing it to be used for any use case without any modifications to the code. This doesn't stop Strapi from providing customization options and extendable code that allows developers to fine-tune Strapi’s internal work to suit a special use case. Let’s dive into the internals of Strapi and how we can customize the backend.
We will be working with the Strapi backend and covering a few aspects of customizations to the Strapi backend. We’re touching on controllers, services, policies, webhooks, and routes.
This article is based on the Strapi internals, customizing the backend workshop video by Richard from StrapiConf 2022
Strapi runs an HTTP server based on Koa, a back-end JavaScript framework.
Koa aims to be a smaller, more expressive, and more robust foundation for web applications and APIs. If you are unfamiliar with the Koa backend framework, you should read the Koa's documentation introduction.
Leveraging Koa, Strapi provides a customizable backend, and according to the backend customization docs, each part of Strapi's backend can be customized:
We’ll be covering these parts of the Strapi backend while building the custom functionality for our order confirmation API
The use case for this is very basic. We’re creating the backend for a shop where we have users that can make orders and can also confirm the orders.
To achieve our use case and build custom functionalities that we need and Strapi does not provide, we’ll get our hands on the backend code and build out those functionalities.
Let’s set up a basic strapi application with the --quickstart
option. This creates a strapi instance with a simple SQLite database.
yarn create strapi-app strapi-backend --quickstart
#OR
npx create-strapi-app@latest strapi-backend --quickstart
I’m using Strapi v4.1.9, which is the latest at the time of creating this project
After installing the Strapi app, run the following command.
yarn develop
#OR
npm run develop
This should open up a new tab in the browser to http://localhost:1337/admin
, redirecting us to the registration page where we will create an admin user.
We’ll enter our details and once this is done, hit the “Let’s start” button. A new admin account will be created, and we’ll be redirected back to http://localhost:1337/admin/
.
Now, let’s quickly create two content types: Products & Orders.
name
- Short Textproduct_code
- Short TextHere’s what the content type should look like:
owner
- Relation (one-way
relation with User from users-permissions)
products
Relation (many-way
relation with Product )
confirmed
- Booleanconfirmation_date
- DatetimeHere’s what the content type should look like:
We just created content-type models using the Content-Type builder in the admin panel. We could also create these content types using the strapi generate
with Strapi’s interactive CLI tool.
The content-types have the following models files:
schema.json
for the model's schema definition. (generated automatically when creating content-type with either method)lifecycles.js
for lifecycle hooks. This file must be created manually.We can check out the model schema definition for the Products in the ./src/api/product/content-types/product/schema.json
file in our Strapi project code.
1 // ./src/api/product/content-types/product/schema.json
2 {
3 "kind": "collectionType",
4 "collectionName": "products",
5 "info": {
6 "singularName": "product",
7 "pluralName": "products",
8 "displayName": "Product"
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "pluginOptions": {},
14 "attributes": {
15 "name": {
16 "type": "string"
17 },
18 "product_code": {
19 "type": "string"
20 }
21 }
22 }
The model schema definition for Order would also be in the ./src/api/order/content-types/order/schema.json
file.
1 // ./src/api/order/content-types/order/schema.json
2
3 {
4 "kind": "collectionType",
5 "collectionName": "orders",
6 "info": {
7 "singularName": "order",
8 "pluralName": "orders",
9 "displayName": "Order",
10 "description": ""
11 },
12 "options": {
13 "draftAndPublish": true
14 },
15 "pluginOptions": {},
16 "attributes": {
17 "owner": {
18 // define a relational field
19 "type": "relation",
20 "relation": "oneToOne",
21 "target": "plugin::users-permissions.user"
22 },
23 "confirmed": {
24 "type": "boolean"
25 },
26 "confirmation_date": {
27 "type": "datetime"
28 },
29 "products": {
30 "type": "relation",
31 "relation": "oneToMany",
32 "target": "api::product.product"
33 }
34 }
35 }
Now that we’ve seen the models in the backend code, let’s dive into what we're trying to build while exploring these customizations.
As we previously discussed, we’re trying to create a store API and currently, Strapi automatically provides us with routes that perform basic CRUD operations we can take a look at them if we go to SETTINGS in our admin dashboard and then USERS & PERMISSIONS PLUGIN > ROLES > PUBLIC.
In the image above, we can see the default pre-defined routes that Strapi creates for our Order
content type.
We want to take it a step further and add another level of customization. The feature we’re going for is for users to create orders and confirm those orders they’ve made.
A very basic way of achieving this would be by using the update
route on the Order
content type to modify the confirmed
and confirmation_date
fields. But in a lot of situations, we might need more than just that and that’s what we’ll be working on.
The first thing we’ll be doing is to ensure that we have controllers and routes set up, knowing that we want to confirm our orders.
Controllers are a very important aspect of how Strapi works and play a big role in customizing the backend. So, let’s go ahead and create a blank controller and a route for it.
To define a custom controller inside the core controller file for the order
endpoint or collection type, we can pass in a function to the createCoreController
method, which takes in an object as a parameter and destructuring it; we’ll pass in strapi
.
1 // ./src/api/order/controllers/order.js
2 'use strict';
3 /**
4 * order controller
5 */
6 const { createCoreController } = require('@strapi/strapi').factories;
7
8 module.exports = createCoreController('api::order.order', ({strapi}) => ({
9 confirmOrder: async (ctx, next) => {
10 ctx.body = "ok"
11 }
12 }));
Here, the function we passed to createCoreController
returns an object where we can specify an async function confimOrder
, which takes ctx
and next
as parameters. Within this function, we can define a response, ctx.body = "ok"
.
That’s how we can create a custom controller within the core controller in the default order
route file. For illustration, we can completely overwrite an already existing controller, like find
for example:
1 // ./src/api/order/controllers/order.js
2
3 ...
4 module.exports = createCoreController('api::order.order', ({strapi}) => ({
5 confirmOrder: async (ctx, next) => {
6 ctx.body = "ok"
7 },
8 find: async (ctx, next) => {
9 // destructure to get `data` and `meta` which strapi returns by default
10 const {data, meta} = await super.find(ctx)
11
12 // perform any other custom action
13 return {data, meta}
14 }
15 }));
Here, we’ve completely overwritten the default find
controller, although we’re still running the same find function using super.find(ctx)
. Now, we can add the main logic behind our confirmOrder
controller.
Remember that we’re trying to create a controller to confirm orders. Here are a few things we need to know:
To know what order is being confirmed, we’ll have to get the id
of that order from the route, so the route path
we’ll create later on will include a dynamic :id
parameter, which is what we’ll pull out from ctx.request.params
in our controller.
1 // ./src/api/order/controllers/order.js
2
3 module.exports = createCoreController('api::order.order', ({strapi}) => ({
4 confirmOrder: async (ctx, next) => {
5 const {id} = ctx.request.params
6 console.log(id);
7 },
8 }));
The next thing we need to do is to create a route that will be able to run our controller.
We will create custom route definitions for our confirmOrder
controller. If we take a look at the already created order.js
route, we’ll see that the core route has already been created:
1 // ./src/api/order/routes/order.js
2
3 'use strict';
4 /**
5 * order router.
6 */
7 const { createCoreRouter } = require('@strapi/strapi').factories;
8 module.exports = createCoreRouter('api::order.order'); // core route already created
We don’t have to make any modifications here to create our custom routes; we can create a new file for that. To access the controller we just created from the API, we need to attach it to a route.
Create a new file to contain our custom route definitions in the order/routes
directory - ./src/api/order/routes/confirm-order.js
1 // ./src/api/order/routes/confirm-order.js
2
3 module.exports = {
4 routes: [
5 {
6 method: "POST",
7 path: "/orders/confirm/:id",
8 handler: "order.confirmOrder"
9 }
10 ]
11 }
We’re creating an object with a routes
key, which has a value of an array of route objects.
The first object here defines a route with the method
of POST
and a path
- /orders/confirm/:id
, where the /:id
is a dynamic URL parameter and is going to change based on the id
of the order we’re trying to confirm.
It also defines the handler
, which is the controller that will be used in the route and in our case, that would be the confirmOrder
controller we created.
Let’s test our custom routes and controllers now shall we? Run:
yarn develop
Once the app is running, we can start sending requests with any API tester of our choice. I’ll be using Thunder Client. It’s a VSCode extension, you can download it from the marketplace.
Once, you’ve gotten your API tester set up, send a POST
request to http://localhost:1337/api/orders/confirm/1
.
As you can see, we’re getting a 403
forbidden error. That’s because Strapi doesn't return anything for unauthenticated routes by default. We need to modify the Permissions in Strapi in order for it to be available to the public.
To do that, go to the Strapi admin dashboard, then go to SETTINGS in our admin dashboard and then USERS & PERMISSIONS PLUGIN > ROLES > PUBLIC.
As you can see, we have a new action - confirmOrder
. Enable it and click on SAVE. Now, you should see the screenshot below if we try to send the request again.
On our server, we can see that it logged the id
as we defined in our controller. We’re now getting a 404
error, don’t worry, a different error is progress. We’re getting a NotFoundError
because we never returned any response in out confirmOrder
controller, we only did a console.log
. Now that we’ve seen that it works, let’s build the main functionality.
Remember, there are a few things we need to know:
id
id
In the controller, let’s return the id
instead of simply logging it:
1 // ./src/api/order/controllers/order.js
2 confirmOrder: async (ctx, next) => {
3 const {id} = ctx.request.params
4 return id
5 },
Send the request again:
Great! That works. We’ve been able to get the order id
; let’s move further to get the user to send the request.
In the confimOrder
controller, we can get the authenticated user
from the context state - ctx.state
1 // ./src/api/order/controllers/order.js
2 ...
3 confirmOrder: async (ctx, next) => {
4 const {id} = ctx.request.params
5 console.log(ctx.state.user)
6 return id
7 },
If we send this request, we’ll see that the server logs out undefined
.
That’s because we’re sending a request without authentication. Let’s create a new user to send requests from. In the Strapi dashboard, go to the CONTENT MANAGER > USER and click on CREATE NEW ENTRY to create a new user.
Make sure to set the role to Authenticated.
Next, we will send a login request with our newly created user details. In our API tester, send a POST
request to the http://localhost:1337/api/auth/local
endpoint and we’ll have all the details of that user including the JWT.
We’ll copy the token in the jwt
field. We’ll need that to get our user in the confirm confirmation request. To do that, we’ll have to set Authorization headers in our API Tester.
In the case of this extension, we can use the Auth options provided and place the token in the Bearer field.
Now, we’ll head over to the Strapi admin and set the permissions for Public and Authenticated users. In the Strapi admin dashboard, go to SETTINGS and then USERS & PERMISSIONS PLUGIN > ROLES > PUBLIC. Disable the Order
actions and click the Save button. Next, go back to ROLES and select AUTHENTICATED. Enable the actions for Order
.
Once this is done, we’ll head back and send the request to http://localhost:1337/api/orders/confirm/1
with the authorization headers.
Awesome! All the user details are being logged out here on the console.
Make sure never to return sensitive user information in the user object as an API response. Ensure you always sanitize your responses from sensitive data.
Moving on, now that we have the order id
and can see who's confirming the order, we are going to get the order data by using Strapi’s entityService
. Here’s an example of how we can use the entityService
1 // ./src/api/order/controllers/order.js
2 ...
3 confirmOrder: async (ctx, next) => {
4 const {id} = ctx.request.params
5 const user = ctx.state.user
6
7 // using the entityService to get content from strapi
8 // entityService provides a few CRUD operations we can use
9 // we'll be using findOne to get an order by id
10 const order = await strapi.entityService.findOne("api::order.order", id)
11 console.log(order)
12 return id
13 },
The entityService.findOne()
takes in two parameters:
uid
of what we’re trying to find, which for the order is api::order.order
id
of the order in this caseSave the changes, wait for the server to restart and then send another request to the confirm endpoint
So, it returns null
which is okay since we don’t have any order created yet.
Next, we need to change the state of its confirmation and change the confirmation date
To do that, we’ll use the update
method from entityService
to update the order
1 // ./src/api/order/controllers/order.js
2 ...
3 confirmOrder: async (ctx, next) => {
4 const { id } = ctx.request.params
5 await strapi.entityService.update("api::order.order", id , {
6 data: {
7 confirmed: true,
8 confirmation_date: new Date()
9 }
10 })
11 return {
12 message: "confirmed"
13 }
14 },
Here, you can see that we’re passing two things to the update()
method:
uid
- api::order.order
andid
of the order
we want to update and params
object which contains a data
key with the value of an object where we set confirmed
to true
and assign a confimation_date
with new Date()
Now that we’ve seen how to update an order, remember that we don’t have any orders created yet. Let’s work on that.
Before we go into that, if we look at the order
content type, we’ll see that it has an owner
field.
When creating a new order using the default order
controller, the owner
will have to be provided with the API request. Any user can send a request and specify a different user in the owner
field. That would be problematic. We don’t want that.
What we can do instead is to modify the default controller so that the owner
of the order can be inferred from the request context. Let’s enable the create
action for orders in the Authenticated Permissions settings
Hit Save. Now, we can go back to our code to customize the create
controller
Let’s see how we can achieve that:
1 // ./src/api/order/controllers/order.js
2 ...
3 confirmOrder: async (ctx, next) => {
4 ...
5 },
6
7 // customizing the create controller
8 async create(ctx, next){
9 // get user from context
10 const user = ctx.state.user
11 // get request body data from context
12 const { products } = ctx.request.body.data
13 console.log(products);
14 // use the create method from Strapi enitityService
15 const order = await strapi.entityService.create("api::order.order", {
16 data: {
17 products,
18 // pass in the owner id to define the owner
19 owner: user.id
20 }
21 })
22 return { order }
23 }
We have a few things going on here. We:
ctx.state.user
, ctx.request.body.data
strapi.entityService.create()
, pass the uid
- "api::order.order"
and an object. The object we’re passing as parameters is similar to our request body but with the addition of the owner id
. To try out our customized create order controller, we must create a few products first. So, let’s head back to Strapi admin and navigate to CONTENT MANAGER > COLLECTION TYPES > PRODUCT > CREATE NEW ENTRY and create a new product.
Enter the name of the product and the product code and click on SAVE and then PUBLISH.
Great!
Now, let’s send a new POST
request to the orders endpoint - http://localhost:1337/api/orders
with authorization and the following body:
1 {
2 "data": {
3 "products": [
4 2
5 ]
6 }
7 }
We should see a new order created with the owner field populated.
If we check the dashboard, we can see the new order:
Great!
Let’s try to confirm our newly created order and see what happens.
It works! If we check our Strapi dashboard, we should see it confirmed too.
We’ve been able to create custom routes and customize Strapi controllers, allowing us to perform custom actions, which we would not be able to with the default Strapi functionality.
Currently, orders can be confirmed by just passing the order id
to the request body. Any (authenticated) user can pass that id
in a request and confirm that order. We don’t want that. Although an authenticated user can only create orders, we only want the user who created the order to be able to confirm the order.
We'll complete the next part of this article building out our order confirmation use case while exploring other customizations like Policies, utilities.
The backend code for this part of the article can be accessed from here.